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 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
+ "![#{alt}](#{escape_url(attributes[:url])})"
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: []