defmodule Jido.Chat.GitHub.Adapter do
@moduledoc "GitHub Issues `Jido.Chat.Adapter` implementation."
use Jido.Chat.Adapter
alias Jido.Chat.{
Attachment,
Author,
EventEnvelope,
FileUpload,
Incoming,
Media,
Message,
MessagePage,
PostPayload,
ReactionEvent,
Response,
Thread,
ThreadPage,
ThreadSummary,
WebhookRequest,
WebhookResponse
}
alias Jido.Chat.GitHub.Transport.ReqClient
@github_reactions %{
"+1" => "+1",
"thumbsup" => "+1",
"thumbs_up" => "+1",
<<0x1F44D::utf8>> => "+1",
"-1" => "-1",
"thumbsdown" => "-1",
"thumbs_down" => "-1",
<<0x1F44E::utf8>> => "-1",
"laugh" => "laugh",
"smile" => "laugh",
<<0x1F604::utf8>> => "laugh",
"confused" => "confused",
<<0x1F615::utf8>> => "confused",
"heart" => "heart",
<<0x2764::utf8>> => "heart",
<<0x2764::utf8, 0xFE0F::utf8>> => "heart",
"hooray" => "hooray",
"tada" => "hooray",
<<0x1F389::utf8>> => "hooray",
"rocket" => "rocket",
<<0x1F680::utf8>> => "rocket",
"eyes" => "eyes",
<<0x1F440::utf8>> => "eyes"
}
@impl true
def channel_type, do: :github
@impl true
def capabilities do
%{
send_message: :native,
edit_message: :native,
delete_message: :native,
fetch_thread: :native,
fetch_message: :native,
fetch_messages: :native,
list_threads: :native,
open_thread: :native,
post_message: :native,
post_channel_message: :native,
webhook: :native,
verify_webhook: :native,
parse_event: :native,
format_webhook_response: :native,
send_file: :native,
start_typing: :unsupported,
add_reaction: :native,
remove_reaction: :native,
post_ephemeral: :unsupported,
open_modal: :unsupported,
fetch_channel_messages: :native,
markdown: :native,
multi_file: :native
}
end
@impl true
def transform_incoming(
%{"comment" => comment, "issue" => issue, "repository" => repo} = payload
) do
{:ok, incoming_from_comment(comment, issue, repo, payload)}
end
def transform_incoming(%{"issue" => issue, "repository" => repo} = payload) do
{:ok, incoming_from_issue(issue, repo, payload)}
end
def transform_incoming(_), do: {:error, :unsupported_payload}
@impl true
def send_message(room_id, text, opts \\ []) do
with {:ok, target} <- parse_room_id(room_id),
{:ok, body} <- render_text_body(text, opts),
{:ok, raw} <-
transport(opts).create_issue_comment(
target.owner,
target.repo,
target.issue_number,
body,
opts
) do
{:ok, response_from_comment(raw, room_id)}
end
end
@impl true
def post_message(room_id, payload, opts \\ [])
def post_message(room_id, %PostPayload{} = payload, opts) do
case parse_room_id(room_id) do
{:ok, target} ->
post_thread_payload(room_id, target, payload, opts)
{:error, _reason} ->
post_repo_payload(room_id, payload, opts)
end
end
def post_message(room_id, payload, opts) when is_map(payload) do
post_message(room_id, PostPayload.new(payload), opts)
end
@impl true
def post_channel_message(repo_id, text, opts \\ []) do
post_repo_payload(repo_id, PostPayload.text(text), opts)
end
@impl true
def send_file(room_id, file, opts \\ []) do
upload = FileUpload.normalize(file)
caption = Keyword.get(opts, :caption) || Keyword.get(opts, :text)
payload =
PostPayload.new(%{
kind: :text,
text: caption,
files: [upload],
metadata: Keyword.get(opts, :metadata, %{})
})
post_message(room_id, payload, Keyword.drop(opts, [:caption, :text]))
end
@impl true
def edit_message(room_id, comment_id, text, opts \\ []) do
with {:ok, target} <- parse_room_id(room_id),
{:ok, body} <- render_text_body(text, opts),
{:ok, raw} <-
transport(opts).update_issue_comment(target.owner, target.repo, comment_id, body, opts) do
{:ok, response_from_comment(raw, room_id, :edited)}
end
end
@impl true
def delete_message(room_id, comment_id, opts \\ []) do
with {:ok, target} <- parse_room_id(room_id) do
transport(opts).delete_issue_comment(target.owner, target.repo, comment_id, opts)
end
end
@impl true
def fetch_thread(room_id, opts \\ []) do
with {:ok, target} <- parse_room_id(room_id),
{:ok, issue} <-
transport(opts).get_issue(target.owner, target.repo, target.issue_number, opts) do
{:ok, thread_from_issue(issue, target)}
end
end
@impl true
def open_thread(room_or_repo_id, issue_number, opts \\ []) do
with {:ok, room_id} <- open_thread_room_id(room_or_repo_id, issue_number) do
fetch_thread(room_id, opts)
end
end
@impl true
def fetch_message(room_id, comment_id, opts \\ []) do
with {:ok, target} <- parse_room_id(room_id),
{:ok, comment} <-
transport(opts).get_issue_comment(target.owner, target.repo, comment_id, opts) do
{:ok, message_from_comment(comment, room_id)}
end
end
@impl true
def fetch_messages(room_id, opts \\ []) do
with {:ok, target} <- parse_room_id(room_id),
{:ok, comments} <-
transport(opts).list_issue_comments(
target.owner,
target.repo,
target.issue_number,
opts
) do
{:ok,
MessagePage.new(%{
messages: Enum.map(comments, &message_from_comment(&1, room_id)),
next_cursor: nil,
metadata: %{"raw" => comments}
})}
end
end
@impl true
def list_threads(repo_id, opts \\ []) do
with {:ok, target} <- parse_repo_id(repo_id),
{:ok, issues} <- transport(opts).list_issues(target.owner, target.repo, opts) do
threads =
issues
|> Enum.reject(&pull_request_issue?/1)
|> Enum.map(&thread_summary_from_issue(&1, target))
{:ok,
ThreadPage.new(%{
threads: threads,
next_cursor: nil,
metadata: %{"raw" => issues}
})}
end
end
@impl true
def fetch_channel_messages(repo_id, opts \\ []) do
with {:ok, target} <- parse_repo_id(repo_id),
{:ok, issues} <- transport(opts).list_issues(target.owner, target.repo, opts) do
messages =
issues
|> Enum.reject(&pull_request_issue?/1)
|> Enum.map(&issue_message_from_issue(&1, target))
{:ok,
MessagePage.new(%{
messages: messages,
next_cursor: nil,
metadata: %{"raw" => issues}
})}
end
end
@impl true
def add_reaction(room_id, message_id, emoji, opts \\ []) do
with {:ok, target} <- parse_room_id(room_id),
{:ok, content} <- github_reaction(emoji) do
create_reaction(target, message_id, content, opts)
end
end
defp create_reaction(target, message_id, content, opts) do
case reaction_target(opts) do
:issue ->
transport(opts).create_issue_reaction(
target.owner,
target.repo,
target.issue_number,
content,
opts
)
:comment ->
transport(opts).create_issue_comment_reaction(
target.owner,
target.repo,
message_id,
content,
opts
)
end
|> case do
{:ok, _reaction} -> :ok
{:error, _reason} = error -> error
end
end
@impl true
def remove_reaction(room_id, message_id, emoji, opts \\ []) do
with {:ok, target} <- parse_room_id(room_id),
{:ok, content} <- github_reaction(emoji),
{:ok, reaction_id} <- reaction_id(target, message_id, content, opts) do
case reaction_target(opts) do
:issue ->
transport(opts).delete_issue_reaction(
target.owner,
target.repo,
target.issue_number,
reaction_id,
opts
)
:comment ->
transport(opts).delete_issue_comment_reaction(
target.owner,
target.repo,
message_id,
reaction_id,
opts
)
end
end
end
@impl true
def handle_webhook(%Jido.Chat{} = chat, payload, opts \\ []) when is_map(payload) do
request =
WebhookRequest.new(%{
adapter_name: :github,
headers: opts[:headers] || %{},
payload: payload,
raw: opts[:raw_body] || opts[:raw] || payload,
metadata: %{raw_body: opts[:raw_body] || opts[:raw]}
})
with :ok <- verify_webhook(request, opts),
{:ok, parsed_event} <- parse_event(request, opts) do
route_parsed_event(chat, parsed_event, opts, request)
end
end
@impl true
def verify_webhook(request, opts \\ [])
def verify_webhook(%WebhookRequest{} = request, opts) do
secret = Keyword.get(opts, :webhook_secret) || System.get_env("GITHUB_WEBHOOK_SECRET")
signature = WebhookRequest.header(request, "x-hub-signature-256")
raw = raw_body(request)
cond do
secret in [nil, ""] -> {:error, :missing_webhook_secret}
signature in [nil, ""] -> {:error, :missing_signature}
secure_compare(signature, "sha256=" <> hmac(secret, raw)) -> :ok
true -> {:error, :invalid_signature}
end
end
def verify_webhook(request, opts) when is_map(request) do
request
|> WebhookRequest.new()
|> verify_webhook(opts)
end
@impl true
def parse_event(request, opts \\ [])
def parse_event(%WebhookRequest{} = request, _opts) do
event = WebhookRequest.header(request, "x-github-event")
action = request.payload["action"]
case {event, action} do
{"ping", _} ->
{:ok, :noop}
{"issue_comment", action} when action in ["created", "edited"] ->
with {:ok, incoming} <- transform_incoming(request.payload) do
{:ok, envelope(incoming, request)}
end
{"issues", action} when action in ["opened", "edited", "reopened"] ->
with {:ok, incoming} <- transform_incoming(request.payload) do
{:ok, envelope(incoming, request)}
end
{"reaction", action} when action in ["created", "deleted"] ->
with {:ok, reaction} <- reaction_from_payload(request.payload) do
{:ok, reaction_envelope(reaction, request)}
end
_ ->
{:ok, :noop}
end
end
def parse_event(request, opts) when is_map(request) do
request
|> WebhookRequest.new()
|> parse_event(opts)
end
@impl true
def format_webhook_response({:ok, _chat, _incoming}, _opts), do: WebhookResponse.accepted()
def format_webhook_response({:ok, :noop}, _opts), do: WebhookResponse.accepted()
def format_webhook_response({:error, reason}, _opts),
do: WebhookResponse.error(400, inspect(reason))
def format_webhook_response(_, _opts), do: WebhookResponse.accepted()
defp incoming_from_comment(comment, issue, repo, payload) do
room_id = room_id(repo, issue)
user = comment["user"] || %{}
Incoming.new(%{
external_room_id: room_id,
external_thread_id: to_string(issue["id"] || issue["node_id"] || issue["number"]),
external_message_id: to_string(comment["id"]),
external_user_id: user["id"] || user["login"],
text: comment["body"] || "",
timestamp: comment["created_at"],
author: author(user),
chat_type: :github_issue,
chat_title: issue["title"],
raw: payload,
media: media_from_markdown(comment["body"]),
metadata: %{"html_url" => comment["html_url"], "issue_number" => issue["number"]}
})
end
defp incoming_from_issue(issue, repo, payload) do
room_id = room_id(repo, issue)
user = issue["user"] || %{}
Incoming.new(%{
external_room_id: room_id,
external_thread_id: to_string(issue["id"] || issue["node_id"] || issue["number"]),
external_message_id: to_string(issue["number"] || issue["id"]),
external_user_id: user["id"] || user["login"],
text: issue["body"] || issue["title"] || "",
timestamp: issue["created_at"],
author: author(user),
chat_type: :github_issue,
chat_title: issue["title"],
raw: payload,
media: media_from_markdown(issue["body"]),
metadata: %{
"html_url" => issue["html_url"],
"issue_number" => issue["number"],
"issue_event" => true
}
})
end
defp post_thread_payload(room_id, target, %PostPayload{} = payload, opts) do
with {:ok, body} <- render_post_body(payload, opts),
{:ok, raw} <-
transport(opts).create_issue_comment(
target.owner,
target.repo,
target.issue_number,
body,
opts
) do
{:ok,
raw
|> response_from_comment(room_id)
|> put_response_metadata(:attachments, PostPayload.outbound_attachments(payload))}
end
end
defp post_repo_payload(repo_id, %PostPayload{} = payload, opts) do
with {:ok, target} <- parse_repo_id(repo_id),
{:ok, title} <- issue_title(payload, opts),
{:ok, body} <- render_post_body(payload, opts),
{:ok, raw} <- transport(opts).create_issue(target.owner, target.repo, title, body, opts) do
{:ok,
raw
|> response_from_issue(target)
|> put_response_metadata(:attachments, PostPayload.outbound_attachments(payload))}
end
end
defp message_from_comment(comment, room_id) do
Message.new(%{
id: to_string(comment["id"]),
thread_id: thread_id(room_id),
channel_id: room_id,
text: comment["body"],
raw: comment,
author: author(comment["user"] || %{}),
attachments: media_from_markdown(comment["body"]),
created_at: comment["created_at"],
updated_at: comment["updated_at"],
external_message_id: to_string(comment["id"]),
external_room_id: room_id,
metadata: %{"html_url" => comment["html_url"]}
})
end
defp issue_message_from_issue(issue, target) do
repo = %{"name" => target.repo, "owner" => %{"login" => target.owner}}
room_id = room_id(repo, issue)
Message.new(%{
id: to_string(issue["number"] || issue["id"]),
thread_id: thread_id(room_id),
channel_id: "github:#{target.owner}/#{target.repo}",
text: issue["body"] || issue["title"],
raw: issue,
author: author(issue["user"] || %{}),
attachments: media_from_markdown(issue["body"]),
created_at: issue["created_at"],
updated_at: issue["updated_at"],
external_message_id: to_string(issue["number"] || issue["id"]),
external_room_id: "#{target.owner}/#{target.repo}",
metadata: %{
"html_url" => issue["html_url"],
"issue_number" => issue["number"],
"thread_room_id" => room_id
}
})
end
defp response_from_comment(comment, room_id, status \\ :sent) do
Response.new(%{
external_message_id: to_string(comment["id"]),
external_room_id: room_id,
timestamp: comment["created_at"],
channel_type: :github,
status: status,
raw: comment,
metadata: %{"html_url" => comment["html_url"]}
})
end
defp response_from_issue(issue, target, status \\ :sent) do
room_id = "#{target.owner}/#{target.repo}##{issue["number"]}"
Response.new(%{
external_message_id: to_string(issue["number"] || issue["id"]),
external_room_id: room_id,
timestamp: issue["created_at"],
channel_type: :github,
status: status,
raw: issue,
metadata: %{
"html_url" => issue["html_url"],
"issue_number" => issue["number"],
"repo" => "#{target.owner}/#{target.repo}"
}
})
end
defp put_response_metadata(%Response{} = response, key, value) do
%{response | metadata: Map.put(response.metadata || %{}, key, value)}
end
defp route_parsed_event(chat, :noop, _opts, %WebhookRequest{} = request) do
{:ok, chat, synthetic_incoming(request, :noop)}
end
defp route_parsed_event(chat, %EventEnvelope{} = envelope, opts, _request) do
with {:ok, updated_chat, routed_envelope} <-
Jido.Chat.process_event(chat, :github, envelope, opts),
{:ok, incoming} <- incoming_from_event(routed_envelope) do
{:ok, updated_chat, incoming}
end
end
defp incoming_from_event(%EventEnvelope{event_type: :message, payload: %Incoming{} = incoming}),
do: {:ok, incoming}
defp incoming_from_event(%EventEnvelope{
event_type: :reaction,
payload: %ReactionEvent{} = reaction
}) do
{:ok,
Incoming.new(%{
external_room_id: reaction.channel_id,
external_user_id: reaction.user && reaction.user.user_id,
external_message_id: reaction.message_id,
text: nil,
raw: reaction.raw,
metadata:
Map.merge(reaction.metadata || %{}, %{
"event_type" => :reaction,
"emoji" => reaction.emoji,
"added" => reaction.added
})
})}
end
defp incoming_from_event(_), do: {:error, :unsupported_event_type}
defp synthetic_incoming(%WebhookRequest{} = request, event_type) do
Incoming.new(%{
external_room_id: "github",
external_user_id: nil,
external_message_id: WebhookRequest.header(request, "x-github-delivery"),
text: nil,
raw: request.payload,
metadata: %{event_type: event_type}
})
end
defp author(user) do
Author.new(%{
user_id: to_string(user["id"] || user["login"] || "unknown"),
user_name: user["login"] || "unknown",
full_name: user["login"],
is_bot: user["type"] == "Bot",
metadata: user
})
end
defp reaction_from_payload(%{"reaction" => reaction, "issue" => issue} = payload) do
repo = payload["repository"] || %{}
comment = payload["comment"]
room_id = room_id(repo, issue)
user = reaction["user"] || payload["sender"] || %{}
message_id = if is_map(comment), do: comment["id"], else: issue["number"] || issue["id"]
{:ok,
ReactionEvent.new(%{
adapter_name: :github,
thread_id: thread_id(room_id),
channel_id: room_id,
message_id: to_string(message_id),
emoji: reaction["content"],
added: payload["action"] != "deleted",
user: author(user),
raw: payload,
metadata: %{
"reaction_id" => reaction["id"],
"issue_number" => issue["number"],
"html_url" => if(is_map(comment), do: comment["html_url"], else: issue["html_url"])
}
})}
end
defp reaction_from_payload(_payload), do: {:error, :unsupported_payload}
defp envelope(incoming, request) do
EventEnvelope.new(%{
adapter_name: :github,
event_type: :message,
thread_id: thread_id(incoming.external_room_id),
channel_id: incoming.external_room_id,
message_id: to_string(incoming.external_message_id),
payload: incoming,
raw: request.payload,
metadata: %{"delivery" => WebhookRequest.header(request, "x-github-delivery")}
})
end
defp reaction_envelope(%ReactionEvent{} = reaction, request) do
EventEnvelope.new(%{
adapter_name: :github,
event_type: :reaction,
thread_id: reaction.thread_id,
channel_id: reaction.channel_id,
message_id: reaction.message_id,
payload: reaction,
raw: request.payload,
metadata: %{"delivery" => WebhookRequest.header(request, "x-github-delivery")}
})
end
defp thread_from_issue(issue, target) do
room_id = "#{target.owner}/#{target.repo}##{issue["number"] || target.issue_number}"
Thread.new(%{
id: thread_id(room_id),
adapter_name: :github,
adapter: __MODULE__,
external_room_id: room_id,
external_thread_id: to_string(issue["id"] || issue["node_id"] || target.issue_number),
channel_id: "github:#{target.owner}/#{target.repo}",
metadata: %{"issue" => issue}
})
end
defp thread_summary_from_issue(issue, target) do
repo = %{"name" => target.repo, "owner" => %{"login" => target.owner}}
room_id = room_id(repo, issue)
incoming = incoming_from_issue(issue, repo, %{"issue" => issue, "repository" => repo})
ThreadSummary.new(%{
id: thread_id(room_id),
last_reply_at: issue["updated_at"],
reply_count: issue["comments"] || 0,
root_message:
Message.from_incoming(incoming, adapter_name: :github, thread_id: thread_id(room_id)),
metadata: %{"issue" => issue}
})
end
defp render_text_body(text, opts) do
text
|> to_string()
|> sections_with_reply(opts, [])
|> render_sections()
end
defp render_post_body(%PostPayload{} = payload, opts) do
base =
payload.markdown || payload.formatted || PostPayload.display_text(payload) ||
payload.fallback_text
with {:ok, attachment_sections} <-
attachment_sections(PostPayload.outbound_attachments(payload)) do
base
|> blank_to_nil()
|> sections_with_reply(opts, attachment_sections)
|> render_sections()
end
end
defp issue_title(%PostPayload{} = payload, opts) do
[
Keyword.get(opts, :title),
Keyword.get(opts, :issue_title),
metadata_value(payload.metadata, :title),
PostPayload.display_text(payload),
payload.markdown,
payload.formatted,
payload.fallback_text
]
|> Enum.find_value(&first_title_line/1)
|> case do
nil -> {:error, :missing_issue_title}
title -> {:ok, String.slice(title, 0, 256)}
end
end
defp first_title_line(nil), do: nil
defp first_title_line(value) do
value
|> to_string()
|> String.split("\n")
|> Enum.find_value(fn line ->
case String.trim(line) do
"" -> nil
title -> title
end
end)
end
defp metadata_value(metadata, key) when is_map(metadata),
do: Map.get(metadata, key) || Map.get(metadata, to_string(key))
defp metadata_value(_metadata, _key), do: nil
defp sections_with_reply(base, opts, extra_sections) do
[reply_section(opts), blank_to_nil(base) | extra_sections]
end
defp render_sections(sections) do
body =
sections
|> Enum.reject(&is_nil/1)
|> Enum.join("\n\n")
|> String.trim()
if body == "", do: {:error, :empty_message}, else: {:ok, body}
end
defp reply_section(opts) do
%{
text: Keyword.get(opts, :reply_to_text) || Keyword.get(opts, :quote_text),
message: Keyword.get(opts, :reply_to_id) || Keyword.get(opts, :quote_id),
author: Keyword.get(opts, :reply_author) || Keyword.get(opts, :quote_author)
}
|> render_reply_section()
end
defp render_reply_section(%{text: text, message: message, author: author}) do
text = blank_to_nil(text)
message = blank_to_nil(message)
author = blank_to_nil(author)
case {text, message} do
{nil, nil} -> nil
{nil, message} -> "Replying to #{message}."
{text, message} -> "#{reply_heading(author, message)}\n#{quote_text(text)}"
end
end
defp reply_heading(nil, nil), do: "Replying to:"
defp reply_heading(nil, message), do: "Replying to #{message}:"
defp reply_heading(author, nil), do: "Replying to #{author}:"
defp reply_heading(author, message), do: "Replying to #{author} in #{message}:"
defp quote_text(text) do
text
|> String.split("\n")
|> Enum.map_join("\n", &("> " <> &1))
end
defp attachment_sections(attachments) when is_list(attachments) do
Enum.reduce_while(attachments, {:ok, []}, fn attachment, {:ok, acc} ->
case attachment_section(Attachment.normalize(attachment)) do
{:ok, section} -> {:cont, {:ok, [section | acc]}}
{:error, _reason} = error -> {:halt, error}
end
end)
|> case do
{:ok, sections} -> {:ok, Enum.reverse(sections)}
{:error, _reason} = error -> error
end
end
defp attachment_section(%Attachment{url: url} = attachment) when is_binary(url) and url != "" do
label = attachment_label(attachment, url)
section =
case attachment.kind do
:image -> ""
_ -> "[#{escape_markdown_label(label)}](#{url})"
end
{:ok, section}
end
defp attachment_section(%Attachment{}),
do: {:error, {:unsupported_file_upload, :github_requires_public_url}}
defp attachment_label(%Attachment{} = attachment, url) do
attachment.metadata[:alt_text] ||
attachment.metadata["alt_text"] ||
attachment.filename ||
filename_from_url(url) ||
"attachment"
end
defp media_from_markdown(nil), do: []
defp media_from_markdown(""), do: []
defp media_from_markdown(markdown) when is_binary(markdown) do
images =
~r/!\[([^\]]*)\]\((https?:\/\/[^)\s]+)(?:\s+"[^"]*")?\)/
|> Regex.scan(markdown)
|> Enum.map(fn [_match, alt, url] ->
Media.new(%{
kind: :image,
url: url,
filename: filename_from_url(url),
metadata: %{"alt_text" => alt}
})
end)
linked_files =
~r/(?<!!)\[([^\]]+)\]\((https?:\/\/[^)\s]+)(?:\s+"[^"]*")?\)/
|> Regex.scan(markdown)
|> Enum.map(fn [_match, label, url] ->
Media.new(%{url: url, filename: label || filename_from_url(url)})
end)
images ++ linked_files
end
defp github_reaction(emoji) do
normalized =
emoji
|> to_string()
|> String.trim()
|> String.trim(":")
|> String.downcase()
case Map.fetch(@github_reactions, normalized) do
{:ok, content} -> {:ok, content}
:error -> {:error, {:unsupported_reaction, normalized}}
end
end
defp reaction_id(target, message_id, content, opts) do
case Keyword.get(opts, :reaction_id) do
reaction_id when reaction_id not in [nil, ""] ->
{:ok, reaction_id}
_ ->
find_reaction_id(target, message_id, content, opts)
end
end
defp find_reaction_id(target, message_id, content, opts) do
with {:ok, reactions} <- list_reactions(target, message_id, opts) do
reactions
|> Enum.find(&(Map.get(&1, "content") == content and reaction_user_matches?(&1, opts)))
|> reaction_id_from_match(content)
end
end
defp reaction_id_from_match(%{"id" => id}, _content), do: {:ok, id}
defp reaction_id_from_match(_reaction, content), do: {:error, {:reaction_not_found, content}}
defp list_reactions(target, message_id, opts) do
case reaction_target(opts) do
:issue ->
transport(opts).list_issue_reactions(
target.owner,
target.repo,
target.issue_number,
opts
)
:comment ->
transport(opts).list_issue_comment_reactions(target.owner, target.repo, message_id, opts)
end
end
defp reaction_user_matches?(reaction, opts) do
case Keyword.get(opts, :user_login) do
user_login when user_login in [nil, ""] -> true
user_login -> get_in(reaction, ["user", "login"]) == user_login
end
end
defp reaction_target(opts) do
case Keyword.get(opts, :target, Keyword.get(opts, :target_type, :comment)) do
value when value in [:issue, "issue"] -> :issue
_ -> :comment
end
end
defp open_thread_room_id(room_or_repo_id, issue_number) do
case parse_room_id(room_or_repo_id) do
{:ok, _target} ->
{:ok, room_or_repo_id}
{:error, _reason} ->
with {:ok, repo} <- parse_repo_id(room_or_repo_id),
{:ok, issue_number} <- parse_issue_number(issue_number) do
{:ok, "#{repo.owner}/#{repo.repo}##{issue_number}"}
end
end
end
defp room_id(repo, issue), do: "#{repo["owner"]["login"]}/#{repo["name"]}##{issue["number"]}"
defp thread_id(room_id), do: "github:#{room_id}"
defp parse_room_id(room_id) when is_binary(room_id) do
case Regex.run(~r{\A([^/\s]+)/([^#\s]+)#(\d+)\z}, room_id) do
[_, owner, repo, issue_number] ->
{:ok, %{owner: owner, repo: repo, issue_number: String.to_integer(issue_number)}}
_ ->
{:error, :invalid_room_id}
end
end
defp parse_room_id(_), do: {:error, :invalid_room_id}
defp parse_repo_id(repo_id) when is_binary(repo_id) do
case Regex.run(~r{\A([^/\s]+)/([^#\s]+)(?:#\d+)?\z}, repo_id) do
[_, owner, repo] -> {:ok, %{owner: owner, repo: repo}}
_ -> {:error, :invalid_repo_id}
end
end
defp parse_repo_id(_), do: {:error, :invalid_repo_id}
defp parse_issue_number(value) when is_integer(value), do: {:ok, value}
defp parse_issue_number(value) when is_binary(value) do
case Integer.parse(value) do
{number, ""} -> {:ok, number}
_ -> {:error, :invalid_issue_number}
end
end
defp parse_issue_number(_value), do: {:error, :invalid_issue_number}
defp pull_request_issue?(%{"pull_request" => pull_request}) when is_map(pull_request),
do: true
defp pull_request_issue?(_issue), do: false
defp filename_from_url(url) when is_binary(url) do
url
|> URI.parse()
|> Map.get(:path)
|> case do
nil -> nil
"" -> nil
path -> path |> Path.basename() |> URI.decode()
end
rescue
_ -> nil
end
defp filename_from_url(_url), do: nil
defp escape_markdown_label(label) do
label
|> to_string()
|> String.replace("[", "\\[")
|> String.replace("]", "\\]")
end
defp blank_to_nil(nil), do: nil
defp blank_to_nil(""), do: nil
defp blank_to_nil(value) when is_binary(value), do: value
defp blank_to_nil(value), do: to_string(value)
defp transport(opts), do: Keyword.get(opts, :transport, ReqClient)
defp raw_body(%WebhookRequest{raw: raw}) when is_binary(raw), do: raw
defp raw_body(%WebhookRequest{metadata: %{"raw_body" => raw}}) when is_binary(raw), do: raw
defp raw_body(%WebhookRequest{metadata: %{raw_body: raw}}) when is_binary(raw), do: raw
defp raw_body(%WebhookRequest{payload: payload}), do: Jason.encode!(payload)
defp hmac(secret, data),
do: :crypto.mac(:hmac, :sha256, secret, data) |> Base.encode16(case: :lower)
defp secure_compare(a, b) when byte_size(a) == byte_size(b) do
:crypto.hash_equals(a, b)
end
defp secure_compare(_, _), do: false
end