chat_sdk-gitlab 1.1.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 +7 -0
- data/lib/chat_sdk/gitlab/adapter.rb +408 -0
- data/lib/chat_sdk/gitlab/api_client.rb +87 -0
- data/lib/chat_sdk/gitlab/card_renderer.rb +113 -0
- data/lib/chat_sdk/gitlab/event_parser.rb +180 -0
- data/lib/chat_sdk/gitlab/format_converter.rb +15 -0
- data/lib/chat_sdk/gitlab.rb +26 -0
- metadata +75 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 503a31094f4c8d541f4b41741fa3efb0549f109146cef596a8cc7770ff3fd8a8
|
|
4
|
+
data.tar.gz: f81ae7bad3b61a5ee2ff20c2db6706744b251c77adf309de9e95ad39ce5e5e09
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 63773ba0ffa5d0ad839bfe36f1d8abcca72d7ef971e9d567342e4859a7e9b3d49d9e3a58ef4a08f302421596d594008002cfcbc6c0aafd63053a8159afbf0c6e
|
|
7
|
+
data.tar.gz: 46f68e8c3cb56ace4b9caf362aaa555bf95d0ba54622d740c0c258f0491f39b31c0da5e1f44d5697de443ee4f5e669032fd9416241c07b8b57a7ac0e5f2f9d9b
|
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "base64"
|
|
4
|
+
require "digest"
|
|
5
|
+
require "openssl"
|
|
6
|
+
require "rack/utils"
|
|
7
|
+
|
|
8
|
+
module ChatSDK
|
|
9
|
+
module GitLab
|
|
10
|
+
class Adapter < ChatSDK::Adapter::Base
|
|
11
|
+
capabilities :edit_messages, :delete_messages, :reactions, :typing_indicator, :message_history
|
|
12
|
+
|
|
13
|
+
DEFAULT_API_URL = "https://gitlab.com/api/v4"
|
|
14
|
+
DEFAULT_BOT_USERNAME = "gitlab-bot"
|
|
15
|
+
MAX_PER_PAGE = 100
|
|
16
|
+
SIGNATURE_TOLERANCE = 5 * 60
|
|
17
|
+
THREAD_ID_PATTERN = /\Agitlab:(\d+):(mr|issue):(\d+)(?::([0-9a-f]+))?\z/
|
|
18
|
+
CHANNEL_ID_PATTERN = /\Agitlab:(\d+)\z/
|
|
19
|
+
NOTE_ID_PATTERN = /\A\d+\z/
|
|
20
|
+
PAGE_PATTERN = /\A[1-9]\d*\z/
|
|
21
|
+
SIGNING_TOKEN_PATTERN = /\A(?:whsec_)?([A-Za-z0-9+\/]{43}=)\z/
|
|
22
|
+
GITLAB_EMOJI_NAMES = {
|
|
23
|
+
"thumbs_up" => "thumbsup",
|
|
24
|
+
"thumbs_down" => "thumbsdown",
|
|
25
|
+
"facepalm" => "face_palm",
|
|
26
|
+
"thinking" => "thinking",
|
|
27
|
+
"hug" => "hugging",
|
|
28
|
+
"medal" => "first_place",
|
|
29
|
+
"memo" => "pencil",
|
|
30
|
+
"email" => "e-mail",
|
|
31
|
+
"rain" => "cloud_rain"
|
|
32
|
+
}.freeze
|
|
33
|
+
NORMALIZED_EMOJI_NAMES = GITLAB_EMOJI_NAMES.invert.freeze
|
|
34
|
+
|
|
35
|
+
attr_reader :client, :bot_user_id, :bot_username
|
|
36
|
+
|
|
37
|
+
def initialize(token: nil, webhook_signing_token: nil, webhook_secret: nil,
|
|
38
|
+
api_url: nil, bot_user_id: nil, bot_username: nil)
|
|
39
|
+
@token = token || ENV["GITLAB_TOKEN"]
|
|
40
|
+
@webhook_signing_token = webhook_signing_token || ENV["GITLAB_WEBHOOK_SIGNING_TOKEN"]
|
|
41
|
+
@webhook_secret = webhook_secret || ENV["GITLAB_WEBHOOK_SECRET"]
|
|
42
|
+
@api_url = api_url || ENV["GITLAB_API_URL"] || DEFAULT_API_URL
|
|
43
|
+
@bot_user_id = bot_user_id || ENV["GITLAB_BOT_USER_ID"]
|
|
44
|
+
configured_username = bot_username || ENV["GITLAB_BOT_USERNAME"]
|
|
45
|
+
@explicit_bot_username = !configured_username.nil? && !configured_username.empty?
|
|
46
|
+
@bot_username = configured_username || DEFAULT_BOT_USERNAME
|
|
47
|
+
|
|
48
|
+
raise ChatSDK::ConfigurationError, "GitLab token required; set GITLAB_TOKEN" if blank?(@token)
|
|
49
|
+
if blank?(@webhook_signing_token) && blank?(@webhook_secret)
|
|
50
|
+
raise ChatSDK::ConfigurationError,
|
|
51
|
+
"GitLab webhook verification required; set GITLAB_WEBHOOK_SIGNING_TOKEN or GITLAB_WEBHOOK_SECRET"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
@bot_user_id = @bot_user_id.to_s unless blank?(@bot_user_id)
|
|
55
|
+
@identity_resolved = @bot_user_id && @explicit_bot_username
|
|
56
|
+
@signing_key = decode_signing_token(@webhook_signing_token) unless blank?(@webhook_signing_token)
|
|
57
|
+
@client = ApiClient.new(token: @token, api_url: @api_url)
|
|
58
|
+
@format_converter = FormatConverter.new
|
|
59
|
+
@card_renderer = CardRenderer.new
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def name
|
|
63
|
+
:gitlab
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def verify_request!(rack_request)
|
|
67
|
+
body = rack_request.body.read
|
|
68
|
+
rack_request.body.rewind
|
|
69
|
+
signature = rack_request.get_header("HTTP_WEBHOOK_SIGNATURE")
|
|
70
|
+
|
|
71
|
+
if @signing_key && signature
|
|
72
|
+
return true if verify_signature(
|
|
73
|
+
body,
|
|
74
|
+
rack_request.get_header("HTTP_WEBHOOK_ID"),
|
|
75
|
+
rack_request.get_header("HTTP_WEBHOOK_TIMESTAMP"),
|
|
76
|
+
signature
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
raise ChatSDK::SignatureVerificationError, "Invalid GitLab webhook signature"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
token = rack_request.get_header("HTTP_X_GITLAB_TOKEN")
|
|
83
|
+
if @webhook_secret && token && secure_token_match?(@webhook_secret, token)
|
|
84
|
+
return true
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
raise ChatSDK::SignatureVerificationError, "Invalid GitLab webhook token or signature"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def ack_response(_rack_request)
|
|
91
|
+
nil
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def parse_events(rack_request)
|
|
95
|
+
payload = read_json_body(rack_request)
|
|
96
|
+
return [] unless payload.is_a?(Hash)
|
|
97
|
+
|
|
98
|
+
event_type = rack_request.get_header("HTTP_X_GITLAB_EVENT")
|
|
99
|
+
expected_kind = {"Note Hook" => "note", "Emoji Hook" => "emoji"}[event_type]
|
|
100
|
+
return [] unless expected_kind && payload["object_kind"] == expected_kind
|
|
101
|
+
return [] unless resolve_bot_identity
|
|
102
|
+
|
|
103
|
+
EventParser.new(
|
|
104
|
+
bot_user_id: @bot_user_id,
|
|
105
|
+
bot_username: @bot_username,
|
|
106
|
+
explicit_bot_username: @explicit_bot_username
|
|
107
|
+
).parse(payload, event_type: event_type)
|
|
108
|
+
rescue JSON::ParserError
|
|
109
|
+
[]
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def encode_thread_id(project_id:, noteable_type:, noteable_iid:, discussion_id: nil)
|
|
113
|
+
project = project_id.to_s
|
|
114
|
+
iid = noteable_iid.to_s
|
|
115
|
+
kind = case noteable_type.to_s
|
|
116
|
+
when "mr", "merge_request", "MergeRequest" then "mr"
|
|
117
|
+
when "issue", "Issue" then "issue"
|
|
118
|
+
else raise ArgumentError, "Invalid GitLab noteable type: #{noteable_type}"
|
|
119
|
+
end
|
|
120
|
+
unless project.match?(/\A\d+\z/) && iid.match?(/\A\d+\z/)
|
|
121
|
+
raise ArgumentError, "GitLab project and noteable IDs must be numeric"
|
|
122
|
+
end
|
|
123
|
+
if discussion_id && !discussion_id.to_s.match?(/\A[0-9a-f]+\z/)
|
|
124
|
+
raise ArgumentError, "Invalid GitLab discussion ID: #{discussion_id}"
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
id = "gitlab:#{project}:#{kind}:#{iid}"
|
|
128
|
+
discussion_id ? "#{id}:#{discussion_id}" : id
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def decode_thread_id(thread_id)
|
|
132
|
+
match = THREAD_ID_PATTERN.match(thread_id.to_s)
|
|
133
|
+
raise ArgumentError, "Invalid GitLab thread ID: #{thread_id}" unless match
|
|
134
|
+
|
|
135
|
+
{
|
|
136
|
+
project_id: match[1].to_i,
|
|
137
|
+
noteable_type: match[2] == "mr" ? :merge_request : :issue,
|
|
138
|
+
noteable_iid: match[3].to_i,
|
|
139
|
+
discussion_id: match[4]
|
|
140
|
+
}
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def channel_id_from_thread_id(thread_id)
|
|
144
|
+
"gitlab:#{decode_thread_id(thread_id)[:project_id]}"
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def post_message(channel_id:, message:, thread_id: nil)
|
|
148
|
+
thread = validated_thread!(channel_id, thread_id)
|
|
149
|
+
note = @client.request_api(
|
|
150
|
+
:post,
|
|
151
|
+
note_collection_path(thread),
|
|
152
|
+
body: {"body" => render(message)}
|
|
153
|
+
)
|
|
154
|
+
@bot_user_id ||= note.dig("author", "id").to_s if note.dig("author", "id")
|
|
155
|
+
build_message(note, thread, channel_id, thread_id)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def edit_message(channel_id:, message_id:, message:, thread_id: nil)
|
|
159
|
+
thread = validated_thread!(channel_id, thread_id)
|
|
160
|
+
validate_message_id!(message_id)
|
|
161
|
+
validate_discussion_note!(thread, message_id)
|
|
162
|
+
note = @client.request_api(
|
|
163
|
+
:put,
|
|
164
|
+
"#{noteable_path(thread)}/notes/#{message_id}",
|
|
165
|
+
body: {"body" => render(message)}
|
|
166
|
+
)
|
|
167
|
+
build_message(note, thread, channel_id, thread_id)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def delete_message(channel_id:, message_id:, thread_id: nil)
|
|
171
|
+
thread = validated_thread!(channel_id, thread_id)
|
|
172
|
+
validate_message_id!(message_id)
|
|
173
|
+
validate_discussion_note!(thread, message_id)
|
|
174
|
+
@client.request_api(:delete, "#{noteable_path(thread)}/notes/#{message_id}")
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def add_reaction(channel_id:, message_id:, emoji:, thread_id: nil)
|
|
178
|
+
thread = validated_thread!(channel_id, thread_id)
|
|
179
|
+
validate_message_id!(message_id)
|
|
180
|
+
validate_discussion_note!(thread, message_id)
|
|
181
|
+
award_path = "#{noteable_path(thread)}/notes/#{message_id}/award_emoji"
|
|
182
|
+
name = to_gitlab_emoji(emoji)
|
|
183
|
+
|
|
184
|
+
begin
|
|
185
|
+
@client.request_api(:post, award_path, body: {"name" => name})
|
|
186
|
+
rescue ChatSDK::PlatformError => error
|
|
187
|
+
raise unless error.status == 404 && @bot_user_id
|
|
188
|
+
raise unless @client.request_all(award_path).any? do |award|
|
|
189
|
+
award["name"] == name && award.dig("user", "id").to_s == @bot_user_id
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def remove_reaction(channel_id:, message_id:, emoji:, thread_id: nil)
|
|
195
|
+
thread = validated_thread!(channel_id, thread_id)
|
|
196
|
+
validate_message_id!(message_id)
|
|
197
|
+
validate_discussion_note!(thread, message_id)
|
|
198
|
+
resolve_bot_identity unless @bot_user_id
|
|
199
|
+
raise ChatSDK::ConfigurationError, "GitLab bot user ID is required to remove reactions" unless @bot_user_id
|
|
200
|
+
|
|
201
|
+
award_path = "#{noteable_path(thread)}/notes/#{message_id}/award_emoji"
|
|
202
|
+
award = @client.request_all(award_path).find do |item|
|
|
203
|
+
item["name"] == to_gitlab_emoji(emoji) &&
|
|
204
|
+
item.dig("user", "id").to_s == @bot_user_id
|
|
205
|
+
end
|
|
206
|
+
return unless award && NOTE_ID_PATTERN.match?(award["id"].to_s)
|
|
207
|
+
|
|
208
|
+
@client.request_api(:delete, "#{award_path}/#{award["id"]}")
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def fetch_messages(channel_id:, thread_id: nil, cursor: nil, limit: 50)
|
|
212
|
+
thread = validated_thread!(channel_id, thread_id)
|
|
213
|
+
per_page = [[limit.to_i, 1].max, MAX_PER_PAGE].min
|
|
214
|
+
|
|
215
|
+
if thread[:discussion_id]
|
|
216
|
+
discussion = @client.request_api(
|
|
217
|
+
:get,
|
|
218
|
+
"#{noteable_path(thread)}/discussions/#{thread[:discussion_id]}"
|
|
219
|
+
)
|
|
220
|
+
notes = Array(discussion["notes"]).reject { |note| internal_note?(note) }
|
|
221
|
+
notes.sort_by! { |note| parse_timestamp(note["created_at"]) || Time.at(0) }
|
|
222
|
+
messages = notes.map { |note| build_message(note, thread, channel_id, thread_id) }
|
|
223
|
+
return [messages.last(per_page), nil]
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
page = PAGE_PATTERN.match?(cursor.to_s) ? cursor.to_s : "1"
|
|
227
|
+
notes, next_page = @client.request_page(
|
|
228
|
+
"#{noteable_path(thread)}/notes",
|
|
229
|
+
query: {
|
|
230
|
+
order_by: "created_at",
|
|
231
|
+
sort: "desc",
|
|
232
|
+
per_page: per_page,
|
|
233
|
+
page: page
|
|
234
|
+
}
|
|
235
|
+
)
|
|
236
|
+
notes = notes.is_a?(Array) ? notes.reverse : []
|
|
237
|
+
messages = notes.reject { |note| internal_note?(note) }
|
|
238
|
+
.map { |note| build_message(note, thread, channel_id, thread_id) }
|
|
239
|
+
[messages, next_page.to_s.empty? ? nil : next_page]
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def start_typing(channel_id:, thread_id: nil, message_id: nil)
|
|
243
|
+
validated_thread!(channel_id, thread_id)
|
|
244
|
+
nil
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def mention(user)
|
|
248
|
+
"@#{user.to_s.sub(/\A@/, "")}"
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def render(postable_message)
|
|
252
|
+
message = ChatSDK::PostableMessage.from(postable_message)
|
|
253
|
+
return @card_renderer.render(message.card) if message.card?
|
|
254
|
+
|
|
255
|
+
@format_converter.from_markdown(message.text)
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
private
|
|
259
|
+
|
|
260
|
+
def resolve_bot_identity
|
|
261
|
+
return true if @identity_resolved
|
|
262
|
+
|
|
263
|
+
user = @client.current_user
|
|
264
|
+
@bot_user_id ||= user["id"].to_s if user["id"]
|
|
265
|
+
if !@explicit_bot_username && user["username"]
|
|
266
|
+
@bot_username = user["username"]
|
|
267
|
+
end
|
|
268
|
+
@identity_resolved = !blank?(@bot_user_id) &&
|
|
269
|
+
(@explicit_bot_username || !blank?(user["username"]))
|
|
270
|
+
@bot_user_id || @explicit_bot_username
|
|
271
|
+
rescue ChatSDK::PlatformError, Faraday::Error
|
|
272
|
+
@bot_user_id || @explicit_bot_username
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def verify_signature(body, message_id, timestamp, signature)
|
|
276
|
+
return false if blank?(message_id) || blank?(timestamp)
|
|
277
|
+
return false unless timestamp.to_s.match?(/\A\d+\z/)
|
|
278
|
+
return false if (Time.now.to_i - timestamp.to_i).abs > SIGNATURE_TOLERANCE
|
|
279
|
+
|
|
280
|
+
digest = OpenSSL::HMAC.digest("SHA256", @signing_key, "#{message_id}.#{timestamp}.#{body}")
|
|
281
|
+
expected = "v1,#{Base64.strict_encode64(digest)}"
|
|
282
|
+
signature.split(/\s+/).any? do |candidate|
|
|
283
|
+
candidate.start_with?("v1,") &&
|
|
284
|
+
candidate.bytesize == expected.bytesize &&
|
|
285
|
+
Rack::Utils.secure_compare(candidate, expected)
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def secure_token_match?(expected, received)
|
|
290
|
+
expected_digest = Digest::SHA256.hexdigest(expected)
|
|
291
|
+
received_digest = Digest::SHA256.hexdigest(received)
|
|
292
|
+
Rack::Utils.secure_compare(expected_digest, received_digest)
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def decode_signing_token(token)
|
|
296
|
+
match = SIGNING_TOKEN_PATTERN.match(token.to_s)
|
|
297
|
+
raise ChatSDK::ConfigurationError, "Invalid GitLab webhook signing token" unless match
|
|
298
|
+
|
|
299
|
+
Base64.strict_decode64(match[1])
|
|
300
|
+
rescue ArgumentError
|
|
301
|
+
raise ChatSDK::ConfigurationError, "Invalid GitLab webhook signing token"
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def validated_thread!(channel_id, thread_id)
|
|
305
|
+
thread = decode_thread_id(thread_id)
|
|
306
|
+
channel_project = decode_channel_id(channel_id)
|
|
307
|
+
unless channel_project == thread[:project_id]
|
|
308
|
+
raise ArgumentError, "GitLab thread #{thread_id} does not belong to channel #{channel_id}"
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
thread
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def decode_channel_id(channel_id)
|
|
315
|
+
match = CHANNEL_ID_PATTERN.match(channel_id.to_s)
|
|
316
|
+
raise ArgumentError, "Invalid GitLab channel ID: #{channel_id}" unless match
|
|
317
|
+
|
|
318
|
+
match[1].to_i
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def validate_message_id!(message_id)
|
|
322
|
+
raise ArgumentError, "Invalid GitLab note ID: #{message_id}" unless NOTE_ID_PATTERN.match?(message_id.to_s)
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def validate_discussion_note!(thread, message_id)
|
|
326
|
+
discussion_id = thread[:discussion_id]
|
|
327
|
+
return unless discussion_id
|
|
328
|
+
|
|
329
|
+
discussion = @client.request_api(
|
|
330
|
+
:get,
|
|
331
|
+
"#{noteable_path(thread)}/discussions/#{discussion_id}"
|
|
332
|
+
)
|
|
333
|
+
return if Array(discussion["notes"]).any? { |note| note["id"].to_s == message_id.to_s }
|
|
334
|
+
|
|
335
|
+
raise ArgumentError, "GitLab note #{message_id} does not belong to discussion #{discussion_id}"
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
def noteable_path(thread)
|
|
339
|
+
collection = thread[:noteable_type] == :merge_request ? "merge_requests" : "issues"
|
|
340
|
+
"/projects/#{thread[:project_id]}/#{collection}/#{thread[:noteable_iid]}"
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
def note_collection_path(thread)
|
|
344
|
+
base = noteable_path(thread)
|
|
345
|
+
discussion_id = thread[:discussion_id]
|
|
346
|
+
discussion_id ? "#{base}/discussions/#{discussion_id}/notes" : "#{base}/notes"
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
def build_message(note, thread, channel_id, thread_id)
|
|
350
|
+
author = note["author"].is_a?(Hash) ? note["author"] : {}
|
|
351
|
+
author_id = author["id"] || "bot"
|
|
352
|
+
author_name = author["name"] || author["username"] || "GitLab bot"
|
|
353
|
+
author_username = author["username"].to_s
|
|
354
|
+
text = @format_converter.to_markdown(note["body"] || "")
|
|
355
|
+
ChatSDK::Message.new(
|
|
356
|
+
id: note["id"].to_s,
|
|
357
|
+
text: text,
|
|
358
|
+
author: ChatSDK::Author.new(
|
|
359
|
+
id: author_id.to_s,
|
|
360
|
+
name: author_name,
|
|
361
|
+
platform: :gitlab,
|
|
362
|
+
bot: author["bot"] == true ||
|
|
363
|
+
EventParser::PROJECT_BOT_USERNAME.match?(author_username) ||
|
|
364
|
+
(@bot_user_id && author_id.to_s == @bot_user_id) ||
|
|
365
|
+
author_username.casecmp?(@bot_username)
|
|
366
|
+
),
|
|
367
|
+
thread_id: thread_id,
|
|
368
|
+
channel_id: channel_id,
|
|
369
|
+
platform: :gitlab,
|
|
370
|
+
raw: {
|
|
371
|
+
note: note,
|
|
372
|
+
project_id: thread[:project_id],
|
|
373
|
+
noteable_type: thread[:noteable_type],
|
|
374
|
+
noteable_iid: thread[:noteable_iid],
|
|
375
|
+
discussion_id: thread[:discussion_id]
|
|
376
|
+
},
|
|
377
|
+
timestamp: parse_timestamp(note["created_at"]),
|
|
378
|
+
is_mention: mention_in?(text)
|
|
379
|
+
)
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
def mention_in?(text)
|
|
383
|
+
masked = ChatSDK::Format::Mentions.mask_code_spans(text)
|
|
384
|
+
username = Regexp.escape(@bot_username.to_s.sub(/\A@/, ""))
|
|
385
|
+
masked.match?(/(?<!\w)@#{username}(?![\w-])/i)
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
def internal_note?(note)
|
|
389
|
+
note["system"] == true || note["internal"] == true || note["confidential"] == true
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
def parse_timestamp(value)
|
|
393
|
+
Time.parse(value) if value
|
|
394
|
+
rescue ArgumentError
|
|
395
|
+
nil
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
def to_gitlab_emoji(emoji)
|
|
399
|
+
name = emoji.respond_to?(:name) ? emoji.name.to_s : emoji.to_s
|
|
400
|
+
GITLAB_EMOJI_NAMES.fetch(name, name)
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
def blank?(value)
|
|
404
|
+
value.nil? || value.to_s.empty?
|
|
405
|
+
end
|
|
406
|
+
end
|
|
407
|
+
end
|
|
408
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module ChatSDK
|
|
6
|
+
module GitLab
|
|
7
|
+
class ApiClient < ChatSDK::ApiClient::Base
|
|
8
|
+
MAX_PAGES = 20
|
|
9
|
+
PER_PAGE = 100
|
|
10
|
+
|
|
11
|
+
def initialize(token:, api_url:)
|
|
12
|
+
@token = token
|
|
13
|
+
@api_url = api_url.sub(%r{/+\z}, "")
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def current_user
|
|
17
|
+
request(:get, "/user")
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def request_api(method, path, body: nil, query: nil)
|
|
21
|
+
query_string = URI.encode_www_form(query || {})
|
|
22
|
+
request(method.to_sym, query_string.empty? ? path : "#{path}?#{query_string}", body)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def request_page(path, query:)
|
|
26
|
+
response = connection.get(resolve_path(path)) do |req|
|
|
27
|
+
req.params.update(query)
|
|
28
|
+
end
|
|
29
|
+
handle_response(response)
|
|
30
|
+
[response.body, response.headers["x-next-page"]]
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def request_all(path, query: {})
|
|
34
|
+
results = []
|
|
35
|
+
page = "1"
|
|
36
|
+
|
|
37
|
+
MAX_PAGES.times do
|
|
38
|
+
values, next_page = request_page(path, query: query.merge(per_page: PER_PAGE, page: page))
|
|
39
|
+
results.concat(values) if values.is_a?(Array)
|
|
40
|
+
break if next_page.nil? || next_page.empty?
|
|
41
|
+
|
|
42
|
+
page = next_page
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
results
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def base_url
|
|
51
|
+
"#{@api_url}/"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def resolve_path(path)
|
|
55
|
+
path.sub(/\A\//, "")
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def adapter_name
|
|
59
|
+
:gitlab
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def configure_auth(faraday)
|
|
63
|
+
faraday.headers["Authorization"] = "Bearer #{@token}"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def extract_retry_after(response)
|
|
67
|
+
value = response.headers["retry-after"]
|
|
68
|
+
value.to_i if value
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def extract_error_message(response)
|
|
72
|
+
body = response.body
|
|
73
|
+
return response.status.to_s unless body.is_a?(Hash)
|
|
74
|
+
|
|
75
|
+
message = body["message"]
|
|
76
|
+
return message if message.is_a?(String)
|
|
77
|
+
if message.is_a?(Hash)
|
|
78
|
+
return message.map do |field, errors|
|
|
79
|
+
"#{field} #{Array(errors).join(", ")}"
|
|
80
|
+
end.join("; ")
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
body["error"] || response.status.to_s
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ChatSDK
|
|
4
|
+
module GitLab
|
|
5
|
+
class CardRenderer
|
|
6
|
+
MARKDOWN_ESCAPED = ["\\", "*", "_", "[", "]"].freeze
|
|
7
|
+
URL_UNSAFE = /[\s()<>]/
|
|
8
|
+
|
|
9
|
+
def render(card)
|
|
10
|
+
lines = []
|
|
11
|
+
title = card.attributes[:title]
|
|
12
|
+
subtitle = card.attributes[:subtitle]
|
|
13
|
+
lines << "**#{escape_markdown(title)}**" if title
|
|
14
|
+
lines << escape_markdown(subtitle) if subtitle
|
|
15
|
+
lines << "" if (title || subtitle) && card.children.any?
|
|
16
|
+
|
|
17
|
+
children = card.children.map { |child| render_child(child) }.reject(&:empty?)
|
|
18
|
+
children.each_with_index do |child, index|
|
|
19
|
+
lines << "" if index.positive?
|
|
20
|
+
lines << child
|
|
21
|
+
end
|
|
22
|
+
lines.join("\n")
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def render_child(child)
|
|
28
|
+
attributes = child.attributes
|
|
29
|
+
|
|
30
|
+
case child.type
|
|
31
|
+
when :text
|
|
32
|
+
render_text(attributes[:content], attributes[:style])
|
|
33
|
+
when :field
|
|
34
|
+
"**#{escape_markdown(attributes[:label])}:** #{escape_markdown(attributes[:value])}"
|
|
35
|
+
when :fields
|
|
36
|
+
child.children.map { |field| render_child(field) }.reject(&:empty?).join("\n")
|
|
37
|
+
when :actions
|
|
38
|
+
child.children.map { |action| render_action(action) }.reject(&:empty?).join(" • ")
|
|
39
|
+
when :section
|
|
40
|
+
section = attributes[:title] ? "### #{escape_markdown(attributes[:title])}" : nil
|
|
41
|
+
([section] + child.children.map { |nested| render_child(nested) }).compact.reject(&:empty?).join("\n")
|
|
42
|
+
when :image
|
|
43
|
+
alt = escape_markdown(attributes[:alt])
|
|
44
|
+
"})"
|
|
45
|
+
when :divider
|
|
46
|
+
"---"
|
|
47
|
+
when :table
|
|
48
|
+
render_table(attributes)
|
|
49
|
+
else
|
|
50
|
+
child.fallback_text
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def render_text(content, style)
|
|
55
|
+
case style&.to_sym
|
|
56
|
+
when :bold then "**#{content}**"
|
|
57
|
+
when :muted then "_#{content}_"
|
|
58
|
+
else content.to_s
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def render_action(action)
|
|
63
|
+
attributes = action.attributes
|
|
64
|
+
label = escape_markdown(attributes[:text])
|
|
65
|
+
|
|
66
|
+
if action.type == :link_button
|
|
67
|
+
"[#{label}](#{escape_url(attributes[:url])})"
|
|
68
|
+
else
|
|
69
|
+
"**[#{label}]**"
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def render_table(attributes)
|
|
74
|
+
headers = Array(attributes[:headers]).map { |value| escape_cell(value) }
|
|
75
|
+
rows = Array(attributes[:rows]).map do |row|
|
|
76
|
+
Array(row).map { |value| escape_cell(value) }
|
|
77
|
+
end
|
|
78
|
+
lines = []
|
|
79
|
+
lines << attributes[:caption].to_s if attributes[:caption]
|
|
80
|
+
|
|
81
|
+
unless headers.empty?
|
|
82
|
+
alignments = Array(attributes[:align])
|
|
83
|
+
lines << "| #{headers.join(" | ")} |"
|
|
84
|
+
lines << "| #{headers.each_index.map { |index| alignment_marker(alignments[index]) }.join(" | ")} |"
|
|
85
|
+
end
|
|
86
|
+
rows.each { |row| lines << "| #{row.join(" | ")} |" }
|
|
87
|
+
lines.join("\n")
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def alignment_marker(alignment)
|
|
91
|
+
case alignment&.to_sym
|
|
92
|
+
when :center then ":---:"
|
|
93
|
+
when :right then "---:"
|
|
94
|
+
else "---"
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def escape_cell(value)
|
|
99
|
+
value.to_s.gsub("|") { "\\|" }.gsub(/[\r\n]+/, " ")
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def escape_markdown(value)
|
|
103
|
+
value.to_s.each_char.map do |character|
|
|
104
|
+
MARKDOWN_ESCAPED.include?(character) ? "\\#{character}" : character
|
|
105
|
+
end.join
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def escape_url(value)
|
|
109
|
+
value.to_s.gsub(URL_UNSAFE) { |character| format("%%%02X", character.ord) }
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module ChatSDK
|
|
6
|
+
module GitLab
|
|
7
|
+
class EventParser
|
|
8
|
+
PROJECT_BOT_USERNAME = /\A(?:project|group)_\d+_bot/
|
|
9
|
+
EMOJI_NAMES = {
|
|
10
|
+
"thumbsup" => "thumbs_up",
|
|
11
|
+
"thumbsdown" => "thumbs_down",
|
|
12
|
+
"face_palm" => "facepalm",
|
|
13
|
+
"hugging" => "hug",
|
|
14
|
+
"first_place" => "medal",
|
|
15
|
+
"pencil" => "memo",
|
|
16
|
+
"e-mail" => "email",
|
|
17
|
+
"cloud_rain" => "rain"
|
|
18
|
+
}.freeze
|
|
19
|
+
|
|
20
|
+
def initialize(bot_user_id:, bot_username:, explicit_bot_username:)
|
|
21
|
+
@bot_user_id = bot_user_id
|
|
22
|
+
@bot_username = bot_username
|
|
23
|
+
@explicit_bot_username = explicit_bot_username
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def parse(payload, event_type:)
|
|
27
|
+
case event_type
|
|
28
|
+
when "Note Hook" then parse_note(payload)
|
|
29
|
+
when "Emoji Hook" then parse_emoji(payload)
|
|
30
|
+
else []
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def parse_note(payload)
|
|
37
|
+
attributes = payload["object_attributes"]
|
|
38
|
+
return [] unless payload["object_kind"] == "note" && attributes.is_a?(Hash)
|
|
39
|
+
return [] if attributes["action"] && attributes["action"] != "create"
|
|
40
|
+
return [] if attributes["system"] || attributes["internal"] || attributes["confidential"]
|
|
41
|
+
return [] if attributes["event_type"] == "confidential_note" || payload["event_type"] == "confidential_note"
|
|
42
|
+
return [] if payload.dig("issue", "confidential")
|
|
43
|
+
|
|
44
|
+
noteable = resolve_noteable(attributes["noteable_type"], payload)
|
|
45
|
+
return [] unless noteable
|
|
46
|
+
return [] unless numeric?(attributes["id"])
|
|
47
|
+
|
|
48
|
+
author_data = payload["user"].is_a?(Hash) ? payload["user"] : {}
|
|
49
|
+
author_id = author_data["id"] || attributes["author_id"]
|
|
50
|
+
return [] if self_user?(author_id, author_data["username"])
|
|
51
|
+
|
|
52
|
+
project_id = payload["project_id"] || payload.dig("project", "id") || attributes["project_id"]
|
|
53
|
+
return [] unless numeric?(project_id) && numeric?(noteable[:iid])
|
|
54
|
+
|
|
55
|
+
thread_id = encode_thread_id(
|
|
56
|
+
project_id,
|
|
57
|
+
noteable[:type],
|
|
58
|
+
noteable[:iid],
|
|
59
|
+
attributes["discussion_id"]
|
|
60
|
+
)
|
|
61
|
+
channel_id = "gitlab:#{project_id}"
|
|
62
|
+
text = attributes["note"].to_s
|
|
63
|
+
timestamp = parse_timestamp(attributes["created_at"])
|
|
64
|
+
author = parse_author(author_data, author_id)
|
|
65
|
+
message = ChatSDK::Message.new(
|
|
66
|
+
id: attributes["id"].to_s,
|
|
67
|
+
text: text,
|
|
68
|
+
author: author,
|
|
69
|
+
thread_id: thread_id,
|
|
70
|
+
channel_id: channel_id,
|
|
71
|
+
platform: :gitlab,
|
|
72
|
+
raw: payload,
|
|
73
|
+
timestamp: timestamp,
|
|
74
|
+
is_mention: mentions_bot?(text)
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
[ChatSDK::Events::Mention.new(
|
|
78
|
+
message: message,
|
|
79
|
+
thread_id: thread_id,
|
|
80
|
+
channel_id: channel_id,
|
|
81
|
+
platform: :gitlab,
|
|
82
|
+
adapter_name: :gitlab,
|
|
83
|
+
raw: payload,
|
|
84
|
+
timestamp: timestamp
|
|
85
|
+
)]
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def parse_emoji(payload)
|
|
89
|
+
attributes = payload["object_attributes"]
|
|
90
|
+
note = payload["note"]
|
|
91
|
+
return [] unless payload["object_kind"] == "emoji" && attributes.is_a?(Hash)
|
|
92
|
+
return [] unless attributes["awardable_type"] == "Note" && note.is_a?(Hash)
|
|
93
|
+
return [] if note["system"] || note["internal"] || note["confidential"]
|
|
94
|
+
return [] if payload.dig("issue", "confidential")
|
|
95
|
+
return [] unless %w[award revoke].include?(payload["event_type"])
|
|
96
|
+
|
|
97
|
+
noteable = resolve_noteable(note["noteable_type"], payload)
|
|
98
|
+
return [] unless noteable
|
|
99
|
+
|
|
100
|
+
user = payload["user"].is_a?(Hash) ? payload["user"] : {}
|
|
101
|
+
user_id = attributes["user_id"] || user["id"]
|
|
102
|
+
return [] if self_user?(user_id, user["username"])
|
|
103
|
+
|
|
104
|
+
project_id = payload["project_id"] || payload.dig("project", "id")
|
|
105
|
+
return [] unless numeric?(project_id) && numeric?(noteable[:iid]) && numeric?(note["id"])
|
|
106
|
+
|
|
107
|
+
thread_id = encode_thread_id(project_id, noteable[:type], noteable[:iid], note["discussion_id"])
|
|
108
|
+
channel_id = "gitlab:#{project_id}"
|
|
109
|
+
emoji_name = attributes["name"].to_s
|
|
110
|
+
|
|
111
|
+
[ChatSDK::Events::Reaction.new(
|
|
112
|
+
emoji: EMOJI_NAMES.fetch(emoji_name, emoji_name),
|
|
113
|
+
user_id: user_id.to_s,
|
|
114
|
+
message_id: note["id"].to_s,
|
|
115
|
+
thread_id: thread_id,
|
|
116
|
+
channel_id: channel_id,
|
|
117
|
+
added: payload["event_type"] == "award",
|
|
118
|
+
platform: :gitlab,
|
|
119
|
+
adapter_name: :gitlab,
|
|
120
|
+
raw: payload,
|
|
121
|
+
timestamp: parse_timestamp(attributes["created_at"])
|
|
122
|
+
)]
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def resolve_noteable(type, payload)
|
|
126
|
+
case type
|
|
127
|
+
when "MergeRequest"
|
|
128
|
+
iid = payload.dig("merge_request", "iid")
|
|
129
|
+
{type: :merge_request, iid: iid} if iid
|
|
130
|
+
when "Issue"
|
|
131
|
+
iid = payload.dig("issue", "iid")
|
|
132
|
+
{type: :issue, iid: iid} if iid
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def encode_thread_id(project_id, noteable_type, noteable_iid, discussion_id)
|
|
137
|
+
kind = (noteable_type == :merge_request) ? "mr" : "issue"
|
|
138
|
+
thread_id = "gitlab:#{project_id}:#{kind}:#{noteable_iid}"
|
|
139
|
+
discussion_id ? "#{thread_id}:#{discussion_id}" : thread_id
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def parse_author(user, user_id)
|
|
143
|
+
username = user["username"].to_s
|
|
144
|
+
ChatSDK::Author.new(
|
|
145
|
+
id: user_id.to_s,
|
|
146
|
+
name: user["name"] || username,
|
|
147
|
+
platform: :gitlab,
|
|
148
|
+
bot: !!(
|
|
149
|
+
user["bot"] == true || PROJECT_BOT_USERNAME.match?(username) ||
|
|
150
|
+
(@bot_user_id && user_id.to_s == @bot_user_id)
|
|
151
|
+
)
|
|
152
|
+
)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def self_user?(user_id, username)
|
|
156
|
+
return user_id.to_s == @bot_user_id if @bot_user_id
|
|
157
|
+
|
|
158
|
+
@explicit_bot_username && username.to_s.casecmp?(@bot_username)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def mentions_bot?(text)
|
|
162
|
+
masked = ChatSDK::Format::Mentions.mask_code_spans(text)
|
|
163
|
+
username = Regexp.escape(@bot_username.to_s.sub(/\A@/, ""))
|
|
164
|
+
masked.match?(/(?<!\w)@#{username}(?![\w-])/i)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def numeric?(value)
|
|
168
|
+
value.to_s.match?(/\A\d+\z/)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def parse_timestamp(value)
|
|
172
|
+
return unless value
|
|
173
|
+
|
|
174
|
+
Time.parse(value)
|
|
175
|
+
rescue ArgumentError
|
|
176
|
+
nil
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ChatSDK
|
|
4
|
+
module GitLab
|
|
5
|
+
class FormatConverter < ChatSDK::Format::Converter
|
|
6
|
+
def to_markdown(platform_text)
|
|
7
|
+
platform_text.to_s
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def from_markdown(markdown)
|
|
11
|
+
markdown.to_s
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "chat_sdk"
|
|
4
|
+
require "faraday"
|
|
5
|
+
require "faraday/net_http"
|
|
6
|
+
require "rack"
|
|
7
|
+
require "zeitwerk"
|
|
8
|
+
|
|
9
|
+
module ChatSDK
|
|
10
|
+
module GitLab
|
|
11
|
+
class << self
|
|
12
|
+
def loader
|
|
13
|
+
@loader ||= begin
|
|
14
|
+
loader = Zeitwerk::Loader.new
|
|
15
|
+
loader.tag = "chat_sdk-gitlab"
|
|
16
|
+
loader.inflector.inflect("chat_sdk" => "ChatSDK", "gitlab" => "GitLab")
|
|
17
|
+
loader.push_dir("#{__dir__}/gitlab", namespace: ChatSDK::GitLab)
|
|
18
|
+
loader.setup
|
|
19
|
+
loader
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
ChatSDK::GitLab.loader
|
metadata
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: chat_sdk-gitlab
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 1.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Quentin Rousseau
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: chat_sdk
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '0.1'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '0.1'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: faraday
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '2.0'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '2.0'
|
|
40
|
+
description: GitLab merge request and issue comment adapter for ChatSDK
|
|
41
|
+
email:
|
|
42
|
+
- quentin@rootly.com
|
|
43
|
+
executables: []
|
|
44
|
+
extensions: []
|
|
45
|
+
extra_rdoc_files: []
|
|
46
|
+
files:
|
|
47
|
+
- lib/chat_sdk/gitlab.rb
|
|
48
|
+
- lib/chat_sdk/gitlab/adapter.rb
|
|
49
|
+
- lib/chat_sdk/gitlab/api_client.rb
|
|
50
|
+
- lib/chat_sdk/gitlab/card_renderer.rb
|
|
51
|
+
- lib/chat_sdk/gitlab/event_parser.rb
|
|
52
|
+
- lib/chat_sdk/gitlab/format_converter.rb
|
|
53
|
+
homepage: https://github.com/rootlyhq/chat-sdk
|
|
54
|
+
licenses:
|
|
55
|
+
- MIT
|
|
56
|
+
metadata:
|
|
57
|
+
rubygems_mfa_required: 'true'
|
|
58
|
+
rdoc_options: []
|
|
59
|
+
require_paths:
|
|
60
|
+
- lib
|
|
61
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
62
|
+
requirements:
|
|
63
|
+
- - ">="
|
|
64
|
+
- !ruby/object:Gem::Version
|
|
65
|
+
version: 3.2.0
|
|
66
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
67
|
+
requirements:
|
|
68
|
+
- - ">="
|
|
69
|
+
- !ruby/object:Gem::Version
|
|
70
|
+
version: '0'
|
|
71
|
+
requirements: []
|
|
72
|
+
rubygems_version: 4.0.16
|
|
73
|
+
specification_version: 4
|
|
74
|
+
summary: GitLab adapter for ChatSDK
|
|
75
|
+
test_files: []
|