// Matrix Construct // // Copyright (C) Matrix Construct Developers, Authors & Contributors // Copyright (C) 2016-2018 Jason Volk // // Permission to use, copy, modify, and/or distribute this software for any // purpose with or without fee is hereby granted, provided that the above // copyright notice and this permission notice is present in all copies. The // full license for this software is available in the LICENSE file. using namespace ircd; mapi::header IRCD_MODULE { "Web hook Handler" }; conf::item webhook_secret { { "name", "webhook.secret" } }; conf::item webhook_user { { "name", "webhook.user" } }; conf::item webhook_room { { "name", "webhook.room" } }; conf::item webhook_url { { "name", "webhook.url" }, { "default", "/webhook" } }; conf::item webhook_status_verbose { { "name", "webhook.github.status.verbose" }, { "default", true }, }; conf::item webhook_status_errors { { "name", "webhook.github.status.errors" }, { "default", true }, }; conf::item webhook_github_token { { "name", "webhook.github.token" } }; resource webhook_resource { string_view{webhook_url}, { "Webhook Resource", webhook_resource.DIRECTORY } }; static resource::response post__webhook(client &, const resource::request &); resource::method webhook_post { webhook_resource, "POST", post__webhook }; static void github_handle(client &, const resource::request &); static void appveyor_handle(client &, const resource::request &); static void dockerhub_handle(client &, const resource::request &); resource::response post__webhook(client &client, const resource::request &request) { const http::headers &headers { request.head.headers }; if(http::has(headers, "X-GitHub-Event")) github_handle(client, request); else if(http::has(headers, "X-Appveyor-Secret")) appveyor_handle(client, request); else if(startswith(request.head.content_type, "application/json")) dockerhub_handle(client, request); return resource::response { client, http::OK }; } static bool github_validate(const string_view &sig, const const_buffer &content, const string_view &secret); static std::string github_url(const json::string &url); static json::string github_find_commit_hash(const json::object &content); static json::string github_find_issue_number(const json::object &content); static std::pair github_find_party(const json::object &content); static std::pair github_find_repo(const json::object &content); static ircd::m::event::id::buf github_find_push_event_id(const m::room &, const m::user::id &, const string_view &); static bool github_handle__dependabot_alert(std::ostream &, const json::object &content); static bool github_handle__check_suite(std::ostream &, const json::object &content); static bool github_handle__check_run(std::ostream &, const json::object &content); static bool github_handle__workflow_job(std::ostream &, std::ostream &, const json::object &content); static bool github_handle__workflow_run(std::ostream &, std::ostream &, const json::object &content); static bool github_handle__milestone(std::ostream &, const json::object &content); static bool github_handle__gollum(std::ostream &, const json::object &content); static bool github_handle__push(std::ostream &, const json::object &content); static bool github_post_handle__push(const m::event::id &, const json::object &content); static bool github_handle__pull_request(std::ostream &, const json::object &content); static bool github_handle__issue_comment(std::ostream &, const json::object &content); static bool github_handle__commit_comment(std::ostream &, const json::object &content); static bool github_handle__issues(std::ostream &, const json::object &content); static bool github_handle__watch(std::ostream &, const json::object &content); static bool github_handle__star(std::ostream &, const json::object &content); static bool github_handle__label(std::ostream &, const json::object &content); static bool github_handle__organization(std::ostream &, const json::object &content); static bool github_handle__status(std::ostream &, const json::object &content); static bool github_handle__repository(std::ostream &, const json::object &content); static bool github_handle__delete(std::ostream &, const json::object &content); static bool github_handle__create(std::ostream &, const json::object &content); static bool github_handle__ping(std::ostream &, const json::object &content); static std::ostream & github_heading(std::ostream &, const string_view &type, const json::object &content); void github_handle(client &client, const resource::request &request) { if(!string_view(webhook_room)) return; if(!string_view(webhook_user)) return; const http::headers &headers { request.head.headers }; const auto sig { headers.at("X-Hub-Signature") }; if(!github_validate(sig, request.content, webhook_secret)) throw http::error { http::UNAUTHORIZED, "X-Hub-Signature verification failed" }; const string_view &type { headers.at("X-GitHub-Event") }; const string_view &delivery { headers.at("X-GitHub-Delivery") }; const unique_buffer buf[2] { { 48_KiB }, { 4_KiB }, }; std::stringstream out, alt; pubsetbuf(out, buf[0]); pubsetbuf(alt, buf[1]); github_heading(out, type, request.content); alt << type << " by " << github_find_party(request.content).first << " to " << github_find_repo(request.content).first << " at " << github_find_commit_hash(request.content) ; const bool ok { type == "ping"? github_handle__ping(out, request.content): type == "push"? github_handle__push(out, request.content): type == "pull_request"? github_handle__pull_request(out, request.content): type == "issues"? github_handle__issues(out, request.content): type == "issue_comment"? github_handle__issue_comment(out, request.content): type == "commit_comment"? github_handle__commit_comment(out, request.content): type == "watch"? github_handle__watch(out, request.content): type == "star"? github_handle__star(out, request.content): type == "label"? github_handle__label(out, request.content): type == "organization"? github_handle__organization(out, request.content): type == "status"? github_handle__status(out, request.content): type == "repository"? github_handle__repository(out, request.content): type == "create"? github_handle__create(out, request.content): type == "delete"? github_handle__delete(out, request.content): type == "gollum"? github_handle__gollum(out, request.content): type == "milestone"? github_handle__milestone(out, request.content): type == "dependabot_alert"? github_handle__dependabot_alert(out, request.content): type == "workflow_run"? github_handle__workflow_run(out, alt, request.content): type == "workflow_job"? github_handle__workflow_job(out, alt, request.content): type == "check_run"? github_handle__check_run(out, request.content): type == "check_suite"? github_handle__check_suite(out, request.content): true // unhandled will just show heading }; if(!ok) return; const auto room_id { m::room_id(string_view(webhook_room)) }; const m::user::id::buf user_id { string_view(webhook_user), my_host() }; const auto evid { m::msghtml(room_id, user_id, view(out, buf[0]), view(alt, buf[1]), "m.notice") }; if(type == "push") github_post_handle__push(evid, request.content); log::info { "Webhook [%s] '%s' delivered to %s %s", delivery, type, string_view{room_id}, string_view{evid} }; } static std::ostream & github_heading(std::ostream &out, const string_view &type, const json::object &content) { const json::object repository { content["repository"] }; const json::object organization { content["organization"] }; if(empty(repository)) { const auto url { github_url(organization["url"]) }; out << "" << json::string(organization["login"]) << ""; } else out << "" << json::string(repository["full_name"]) << ""; const auto commit_hash { github_find_commit_hash(content) }; if(commit_hash && type == "push") out << " "; else if(commit_hash && type == "pull_request") out << " "; else if(commit_hash) out << " "; if(commit_hash) out << commit_hash.substr(0, 8) << ""; const string_view issue_number { github_find_issue_number(content) }; const json::object workflow { content.has("workflow_run")? content["workflow_run"]: content["workflow_job"] }; const json::string workflow_name{workflow["workflow_name"]}, job_name{workflow["name"]}; if(issue_number) out << " #" << issue_number << ""; else if(workflow_name && job_name) out << " job " << workflow_name << ""; else if(job_name) out << " job " << job_name << ""; else out << " " << type; const auto party { github_find_party(content) }; out << " by " << "" << party.first << ""; return out; } static string_view github_markdown(unique_const_buffer &buf, const string_view &text); bool github_handle__dependabot_alert(std::ostream &out, const json::object &content) { const json::string action{content["action"]}, url{content["html_url"]}; const json::object alert{content["alert"]}, advise{alert["security_advisory"]}, vuln{alert["security_vulnerability"]}, dep{alert["dependency"]}, pkg{dep["package"]}; const json::string ghsa{advise["ghsa_id"]}, cve{advise["cve_id"]}, summary{advise["summary"]}, desc{advise["description"]}, severity{advise["severity"]}, name{pkg["name"]}, path{dep["manifest_path"]}; out << " " << "" << summary << "" << "" << "
🚨 " << "" << cve << "" << " " << "" << ghsa << "" << " severity " << severity << " 🚨
" ; unique_const_buffer buf; const string_view markup { github_markdown(buf, desc) }; out //<< "
" << markup //<< "
" << "
" ; if(path) out << "
"
		<< path
		<< "
" << "
" ; return true; } static size_t clear_reactions(const m::room &room, const m::user::id &user_id, const m::event::id &event_id) { const m::relates relations { index(event_id) }; const auto user_match { [&user_id](const auto &sender) { return sender == user_id; } }; size_t ret(0); relations.for_each("m.annotation", [&] (const auto &ref_idx, const json::object &content, const m::relates_to &relates) { if(!m::query(ref_idx, "sender", user_match)) return true; if(m::redacted(ref_idx)) return true; const auto ref_id(m::event_id(ref_idx)); m::redact(room, user_id, ref_id, "cleared"); ++ret; return true; }); return ret; } template static ircd::m::event::id::buf _find_reaction_id(const m::room &room, const m::user::id &user_id, const m::event::id &event_id, closure&& func) { const m::relates relations { index(event_id) }; const auto user_match { [&user_id](const auto &sender) { return sender == user_id; } }; m::event::id::buf ret; relations.for_each("m.annotation", [&func, &user_match, &ret] (const auto &ref_idx, const json::object &content, const m::relates_to &relates) { if(!m::query(ref_idx, "sender", user_match)) return true; if(m::redacted(ref_idx)) return true; if(func(relates.source)) { ret = m::event_id(ref_idx); return false; } else return true; }); return ret; } static ircd::m::event::id::buf find_reaction_id(const m::room &room, const m::user::id &user_id, const m::event::id &event_id, const string_view &label) { return _find_reaction_id(room, user_id, event_id, [&label] (const auto &relates) { const json::string key { relates["key"] }; return key == label; }); } static ircd::m::event::id::buf find_reaction_id_contains(const m::room &room, const m::user::id &user_id, const m::event::id &event_id, const string_view &label) { return _find_reaction_id(room, user_id, event_id, [&] (const auto &relates) { const json::string key { relates["key"] }; return has(key, label); }); } static bool clear_reaction(const m::room &room, const m::user::id &user_id, const m::event::id &event_id, const string_view &label) { const auto reaction_id { find_reaction_id(room, user_id, event_id, label) }; if(!reaction_id) return false; m::redact(room, user_id, reaction_id, "cleared"); return true; } static bool clear_reaction_contains(const m::room &room, const m::user::id &user_id, const m::event::id &event_id, const string_view &label) { const auto reaction_id { find_reaction_id_contains(room, user_id, event_id, label) }; if(!reaction_id) return false; m::redact(room, user_id, reaction_id, "cleared"); return true; } static ircd::m::event::id::buf github_find_job_table(const m::room &room, const m::user::id &user_id, const string_view &str) { const auto type_match { [](const string_view &type) noexcept { return type == "m.room.message"; } }; const auto user_match { [&user_id](const string_view &sender) noexcept { return sender && sender == user_id; } }; const auto content_match { [&str](const json::object &content) { const json::string &body { content["body"] }; return has(body, str); } }; // Limit the search to a maximum of recent messages from the // webhook user and total messages so we don't run out of control // and scan the whole room history. int lim[2] { 768, 384 }; m::room::events it{room}; for(; it && lim[0] > 0 && lim[1] > 0; --it, --lim[0]) { if(!m::query(std::nothrow, it.event_idx(), "sender", user_match)) continue; --lim[1]; if(!m::query(std::nothrow, it.event_idx(), "type", type_match)) continue; if(!m::query(std::nothrow, it.event_idx(), "content", content_match)) continue; return m::event_id(std::nothrow, it.event_idx()); } return {}; } static json::string github_repopath(const json::object &content) { const json::object repository { content.at("repository") }; const json::string full_name { repository.at("full_name") }; return full_name; } static json::object _github_request(unique_const_buffer &out, const string_view &method, const string_view &url, const string_view &content) { char authorization_buf[128]; const string_view authorization { fmt::sprintf { authorization_buf, "Bearer %s", string_view{webhook_github_token}, } }; const http::header headers[] { { "Accept", "application/json; charset=utf-8" }, { "X-GitHub-Api-Version", "2022-11-28" }, { "Authorization", authorization }, }; const auto num_headers { sizeof(headers) / sizeof(http::header) - empty(webhook_github_token) }; return string_view { rest::request { url, { .method = method, .content = content, .content_type = "application/json; charset=utf-8", .headers = vector_view(headers, num_headers), .out = &out, } } }; } template static json::object github_request(const string_view &content, unique_const_buffer &out, const string_view &method, const string_view &repo, const string_view &fmt, args&&... a) { char path_buf[384]; const string_view path{fmt::sprintf { path_buf, fmt, std::forward(a)... }}; char url_buf[512]; const string_view url{fmt::sprintf { url_buf, "https://api.github.com/repos/%s/%s", repo, path, }}; return _github_request(out, method, url, content); } template static json::object github_request(unique_const_buffer &out, const string_view &method, const string_view &repo, const string_view &fmt, args&&... a) { const auto &content(json::empty_object); return github_request(content, out, method, repo, fmt, std::forward(a)...); } static string_view github_markdown(unique_const_buffer &buf, const string_view &text) { const json::strung content { json::members { { "text", text } } }; return _github_request ( buf, "POST", "https://api.github.com/markdown", content ); } static bool github_hook_for_each(const string_view &repo, const function_bool &closure) { unique_const_buffer buf; const json::array response { github_request ( buf, "GET", repo, "hooks" ) }; for(const json::object hook : response) if(!closure(hook)) return false; return true; } static void github_hook_ping(const string_view &repo, const string_view &hook) { unique_const_buffer buf; github_request ( buf, "POST", repo, "hooks/%s/pings", hook ); } static void github_hook_ping(const string_view &repo) { github_hook_for_each(repo, [&repo] (const json::object &hook) { const json::string id { hook["id"] }; github_hook_ping(repo, id); }); } static bool github_hook_shot_for_each(const string_view &repo, const string_view &hook, const bool &redelivery, const function_bool &closure) { unique_const_buffer buf; const json::array response { github_request ( //TODO: pagination token buf, "GET", repo, "hooks/%s/deliveries?per_page=100", hook ) }; for(const json::object shot : response) if(!closure(shot)) return false; return true; } static void github_hook_shot_retry(const string_view &repo, const string_view &hook, const string_view &id) { unique_const_buffer buf; github_request ( buf, "POST", repo, "hooks/%s/deliveries/%s/attempts", hook, id ); } static bool github_hook_shot_for_each_fail(const string_view &repo, const string_view &hook, const function_bool &closure) { bool ret {true}; github_hook_shot_for_each(repo, hook, true, [&ret, &closure] (const json::object &object) { if(object.at("redelivery")) return true; const json::string status { object["status"] }; if(status == "OK") return false; ret = closure(object); return ret; }); return ret; } static bool github_run_for_each_jobs(const string_view &repo, const string_view &run_id, const function_bool &closure) { for(size_t page(1), i(50); i >= 50; ++page) { unique_const_buffer buf; const json::object response { github_request ( buf, "GET", repo, "actions/runs/%s/jobs?per_page=%zu&page=%zu", run_id, i, page ) }; i = 0; for(const json::object job : json::array(response["jobs"])) if(!closure(job)) return false; else ++i; } return true; } static void github_run_delete(const string_view &repo, const string_view &run_id) { unique_const_buffer buf; github_request(buf, "DELETE", repo, "actions/runs/%s", run_id); } static void github_run_cancel(const string_view &repo, const string_view &run_id) { unique_const_buffer buf; github_request(buf, "POST", repo, "actions/runs/%s/cancel", run_id); } static void github_run_rerun(const string_view &repo, const string_view &run_id) { unique_const_buffer buf; github_request(buf, "POST", repo, "actions/runs/%s/rerun", run_id); } static void github_run_rerun_failed(const string_view &repo, const string_view &run_id) { unique_const_buffer buf; github_request(buf, "POST", repo, "actions/runs/%s/rerun-failed-jobs", run_id); } static void github_run_dispatch(const string_view &repo, const string_view &name, const string_view &ref = "master", const json::members inputs = {}) { const json::strung content{json::members { { "ref", ref }, { "inputs", inputs }, }}; unique_const_buffer buf; github_request(content, buf, "POST", repo, "actions/workflows/%s/dispatches", name); } static bool github_flow_for_each(const string_view &repo, const function_bool &closure, const bool active_only = true) { unique_const_buffer buf; const json::object response { github_request ( //TODO: pagination token buf, "GET", repo, "actions/workflows?per_page=100&page=1" ) }; const json::array workflows { response["workflows"] }; for(const json::object flow : workflows) { const json::string state { flow["state"] }; if(active_only && state != "active") continue; if(!closure(flow)) return false; } return true; } bool github_handle__workflow_run(std::ostream &out, std::ostream &alt, const json::object &content) { const json::object workflow{content["workflow"]}, workflow_run{content["workflow_run"]}; const json::string action{content["action"]}, title{workflow_run["display_title"]}, status{workflow_run["status"]}, conclusion{workflow_run["conclusion"]}, url{workflow_run["html_url"]}, name{workflow_run["name"]}, head_sha{workflow_run["head_sha"]}, created_at{workflow_run["created_at"]}, updated_at{workflow_run["updated_at"]}, run_started_at{workflow_run["run_started_at"]}, attempt{workflow_run["run_attempt"]}, run_id{workflow_run["id"]}; const auto _webhook_room_id { m::room_id(string_view(webhook_room)) }; const m::user::id::buf _webhook_user { string_view{webhook_user}, my_host() }; const m::room _webhook_room { _webhook_room_id }; const auto push_event_id { github_find_push_event_id(_webhook_room, _webhook_user, head_sha) }; const auto &stage { workflow_run["conclusion"] == json::literal_null? status: conclusion }; string_view annote; switch(hash(stage)) { case "queued"_: annote = "🔵"_sv; break; case "in_progress"_: annote = "🟡"_sv; break; case "success"_: annote = "🟢"_sv; break; case "failure"_: annote = "🔴"_sv; break; case "skipped"_: annote = "⭕"_sv; break; case "cancelled"_: annote = "⭕"_sv; break; default: annote = "❓️"_sv; break; } char buf[64] {0}; annote = ircd::strlcpy(buf, annote); annote = ircd::strlcat(buf, " "_sv); annote = ircd::strlcat(buf, name); if(push_event_id && action != "requested") // skip search on first action while(clear_reaction_contains(_webhook_room, _webhook_user, push_event_id, name)); m::annotate(_webhook_room, _webhook_user, push_event_id, annote); if(status == "completed") { const fmt::bsprintf<128> alt { "job status table %s %s %s", github_repopath(content), run_id, attempt, }; const auto job_table_id { github_find_job_table(_webhook_room, _webhook_user, alt) }; if(job_table_id) switch(hash(conclusion)) { case "success"_: case "skipped"_: clear_reactions(_webhook_room, _webhook_user, job_table_id); break; default: clear_reaction(_webhook_room, _webhook_user, job_table_id, "⭕"_sv); break; } } bool outputs{false}; if(action == "requested" && conclusion == "failure" && webhook_status_errors) { outputs = true; out << "
" << "" << " " << " " << "" << name << "" << " " << " " << "" << " failed " << "" << "" ; alt << ' ' << name << ' ' << "failed" ; } return outputs; } bool github_handle__workflow_job(std::ostream &out, std::ostream &alt, const json::object &content) { const json::object workflow_job { content["workflow_job"] }; const json::string action{content["action"]}; // Ignore queued actions. Instead on the first in_progress we'll pull // all jobs from github at once. if(action == "queued") return false; const json::string flow_name{workflow_job["workflow_name"]}, job_name{workflow_job["name"]}, url{workflow_job["html_url"]}, status{workflow_job["status"]}, conclusion{workflow_job["conclusion"]}, head_sha{workflow_job["head_sha"]}, started_at{workflow_job["started_at"]}, completed_at{workflow_job["completed_at"]}, attempt{workflow_job["run_attempt"]}, run_id{workflow_job["run_id"]}, job_id{workflow_job["id"]}; const json::array steps { workflow_job["steps"] }; const auto _webhook_room_id { m::room_id(string_view(webhook_room)) }; const m::user::id::buf _webhook_user { string_view{webhook_user}, my_host() }; const m::room _webhook_room { _webhook_room_id }; static const auto annote{[] (const json::object &workflow_job) { const json::string stage { workflow_job["conclusion"] == json::literal_null? workflow_job["status"]: workflow_job["conclusion"] }; switch(hash(stage)) { case "queued"_: return "🟦"_sv; case "in_progress"_: return "🟨"_sv; case "success"_: return "🟩"_sv; case "failure"_: return "🟥"_sv; case "skipped"_: return "⬜️"_sv; case "cancelled"_: return "⬛️"_sv; default: return "❓️"_sv; } }}; const fmt::bsprintf<128> alt_tab { "job status table %s %s %s", github_repopath(content), run_id, attempt, }; const fmt::bsprintf<128> alt_up { "job status update %s %s %s", github_repopath(content), run_id, attempt, }; // slow this bird down static ctx::mutex mutex; const std::unique_lock lock { mutex }; const auto orig_table_id { github_find_job_table(_webhook_room, _webhook_user, alt_tab) }; const auto last_table_id { github_find_job_table(_webhook_room, _webhook_user, alt_up) }; const unique_mutable_buffer buf { 32_KiB }; char headbuf[512] {0}; std::stringstream heading; pubsetbuf(heading, headbuf); github_heading(heading, "push", content); if(orig_table_id) { const auto old_content { m::get(last_table_id?: orig_table_id, "content") }; const json::string old_tab { json::object(old_content)["formatted_body"] }; const string_view td { between(old_tab, "", "") }; const fmt::bsprintf<512> expect { "", url }; const bool exists { !tokens(td, "​"_sv, [&] (const string_view &cell) { return !startswith(cell, expect); // return false for found }) }; const fmt::bsprintf<512> expect_unmodified { "%s%s", string_view{expect}, annote(workflow_job), }; bool modified { !tokens(td, "​"_sv, [&] (const string_view &cell) { if(!startswith(cell, expect)) return true; return cell == expect_unmodified; // return false for found }) }; const bool cancelled { json::string(workflow_job["conclusion"]) == "cancelled" }; string_view tab; tab = ircd::strlcpy(buf, view(heading, headbuf)); tab = ircd::strlcat(buf, "
"); if(exists && modified && !cancelled) tokens(td, "​"_sv, [&] (const string_view &cell) { if(!startswith(cell, expect)) { tab = ircd::strlcat(buf, cell); tab = ircd::strlcat(buf, "​"_sv); return; } tab = ircd::strlcat(buf, ""); tab = ircd::strlcat(buf, annote(workflow_job)); tab = ircd::strlcat(buf, ""); tab = ircd::strlcat(buf, "​"_sv); }); if(!exists || (modified && cancelled)) github_run_for_each_jobs(github_repopath(content), run_id, [&] (const json::object &workflow_job) { const json::string url{workflow_job["html_url"]}; tab = ircd::strlcat(buf, ""); tab = ircd::strlcat(buf, annote(workflow_job)); tab = ircd::strlcat(buf, ""); tab = ircd::strlcat(buf, "​"_sv); modified = true; return true; }); if(modified) { tab = ircd::strlcat(buf, "
"); m::message(_webhook_room, _webhook_user, json::members { { "body", alt_up }, { "msgtype", "m.notice" }, { "format", "org.matrix.custom.html" }, { "formatted_body", tab }, { "m.new_content", json::members { { "body", alt_up }, { "msgtype", "m.notice" }, { "format", "org.matrix.custom.html" }, { "formatted_body", tab }, }}, { "m.relates_to", json::members { { "event_id", orig_table_id }, { "rel_type", "m.replace" }, }} }); } } else if(json::string(workflow_job["conclusion"]) != "skipped") { string_view tab; tab = ircd::strlcpy(buf, view(heading, headbuf)); tab = ircd::strlcat(buf, "
"); github_run_for_each_jobs(github_repopath(content), run_id, [&] (const json::object &workflow_job) { const json::string url{workflow_job["html_url"]}; tab = ircd::strlcat(buf, ""); tab = ircd::strlcat(buf, annote(workflow_job)); tab = ircd::strlcat(buf, ""); tab = ircd::strlcat(buf, "​"_sv); return true; }); tab = ircd::strlcat(buf, "
"); const auto table_event_id { m::msghtml(_webhook_room, _webhook_user, tab, alt_tab) }; if(table_event_id) { m::annotate(_webhook_room, _webhook_user, table_event_id, "⭕"_sv); m::annotate(_webhook_room, _webhook_user, table_event_id, "🔄"_sv); m::annotate(_webhook_room, _webhook_user, table_event_id, "↪️"_sv); m::annotate(_webhook_room, _webhook_user, table_event_id, "🚮"_sv); } if(lex_cast(attempt) > 1) { const fmt::bsprintf<128> prior_alt { "job status table %s %s %u", github_repopath(content), run_id, lex_cast(attempt) - 1, }; const auto prior_table_id { github_find_job_table(_webhook_room, _webhook_user, prior_alt) }; if(prior_table_id) clear_reactions(_webhook_room, _webhook_user, prior_table_id); } } bool outputs{false}; if(conclusion == "failure" && webhook_status_errors) { outputs = true; out << "
" << "" << " " << " " << "" << flow_name << "" << " " << " " << "" << " failed " << "" << "" << job_name << "" << "" ; alt << ' ' << flow_name << ':' << job_name << ' ' << "failed" ; } return outputs; } static void github_react_handle(const m::event &event, m::vm::eval &) try { if(!webhook_room) return; // XXX alias? if(json::get<"room_id"_>(event) != webhook_room) return; const m::room room { at<"room_id"_>(event) }; const m::user::id user_id { at<"sender"_>(event) }; const m::room::power power { room }; // XXX ??? if(power.level_user(user_id) < 50) return; const json::object relates_to { json::get<"content"_>(event).get("m.relates_to") }; const json::string relates_event_id { relates_to["event_id"] }; const json::string key { relates_to["key"] }; const auto relates_content { m::get(relates_event_id, "content") }; const json::string relates_body { json::object{relates_content}.get("body") }; if(startswith(relates_body, "job status table ")) { const auto suffix { lstrip(relates_body, "job status table ") }; string_view token[3]; ircd::tokens(suffix, ' ', token); const auto &[repopath, run_id, attempt] {token}; assert(repopath); assert(run_id); if(!repopath || !run_id) return; switch(hash(key)) { case hash("🚮"_sv): github_run_delete(repopath, run_id); m::redact(room, user_id, relates_event_id, "deleted"); break; case hash("⭕"_sv): github_run_cancel(repopath, run_id); break; case hash("🔄"_sv): github_run_rerun_failed(repopath, run_id); break; case hash("↪️"_sv): github_run_rerun(repopath, run_id); break; } } else if(startswith(relates_body, "push by ")) { const auto suffix { lstrip(relates_body, "push by ") }; string_view token[5]; ircd::tokens(suffix, ' ', token); const auto &[pusher, _by_, repo, _at_, commit] {token}; assert(pusher); assert(repo); assert(commit); if(!repo) return; if(startswith(key, "▶️")) { const auto id { between(key, '(', ')') }; const auto ref { // commit // hash not supported by github "master" }; github_run_dispatch(repo, id, ref, json::members { }); return; } } } catch(const ctx::interrupted &) { throw; } catch(const std::exception &e) { log::error { "github react handle hook :%s", e.what(), }; } static m::hookfn github_react_hook { github_react_handle, { { "_site", "vm.effect" }, { "type", "m.reaction" }, } }; bool github_handle__check_run(std::ostream &out, const json::object &content) { const json::string action { content["action"] }; const json::object check_run { content["check_run"] }; const json::object check_suite { check_run["check_suite"] }; return false; } bool github_handle__check_suite(std::ostream &out, const json::object &content) { const json::string action { content["action"] }; const json::object check_suite { content["check_suite"] }; return false; } bool github_handle__gollum(std::ostream &out, const json::object &content) { const json::array pages { content["pages"] }; const auto count { size(pages) }; out << " to " << "" << count << "" << " page" << (count != 1? "s" : "") << ":" ; for(const json::object page : pages) { const json::string &action { page["action"] }; const json::string sha { page["sha"] }; out << "
" << "" << sha.substr(0, 8) << "" << " " << action << " " << "" << "" << json::string(page["title"]) << "" << "" ; if(page["summary"] && page["summary"] != "null") { out << " " << "
" << "
"
			;

			static const auto delim("\\r\\n");
			const json::string body(page["summary"]);
			ircd::tokens(body, delim, [&out]
			(const string_view &line)
			{
				out << line << "
"; }); out << "" << "
" << "
" ; } } return true; } bool github_handle__milestone(std::ostream &out, const json::object &content) { const json::string &action { content["action"] }; const json::object milestone { content["milestone"] }; out << " " << action << " " << "" << "" << json::string(milestone["title"]) << "" << "" << ' ' ; const json::string &state { milestone["state"] }; if(state == "open") out << "" ; else if(state == "closed") out << "" ; out << " " << state << " " << "" ; out << ' ' << "
"
	<< json::string(milestone["description"])
	<< "
" ; out << ' ' << "Issues" << ' ' << "open" << " " << "" << "" << milestone["open_issues"] << "" << "" << ' ' << "closed" << ' ' << "" << "" << milestone["closed_issues"] << "" << "" ; return true; } bool github_handle__push(std::ostream &out, const json::object &content) { const bool created { content.get("created", false) }; const bool deleted { content.get("deleted", false) }; const bool forced { content.get("forced", false) }; const json::array commits { content["commits"] }; const auto count { size(commits) }; if(!count && deleted) { out << " "; if(content["ref"]) out << " " << json::string(content["ref"]); out << " deleted"; return true; } if(!count && !webhook_status_verbose) return false; if(content["ref"]) { const json::string ref(content["ref"]); out << " " << " " << token_last(ref, '/'); } out << " " << "" << count << " commits" << ""; if(content["forced"] == "true") out << " (rebase)"; out << "
";
	for(ssize_t i(count - 1); i >= 0; --i)
	{
		const json::object &commit(commits.at(i));
		const json::string url(commit["url"]);
		const json::string id(commit["id"]);
		const auto sid(id.substr(0, 8));
		out << " "
		    << "" << sid << ""
		    << "";

		const json::object author(commit["author"]);
		out << " "
		    << json::string(author["name"])
		    << ""
		    ;

		const json::object committer(commit["committer"]);
		if(committer["email"] != author["email"])
			out << " via "
			    << json::string(committer["name"])
			    << ""
			    ;

		const json::string message(commit["message"]);
		const auto summary
		{
			split(message, "\\n").first
		};

		out << " "
		    << summary
		    ;

		out << "\n";
	}

	out << "
"; return true; } static bool github_post_handle__push(const m::event::id &push_event_id, const json::object &content) { const m::user::id::buf _webhook_user { string_view{webhook_user}, my_host() }; const auto _webhook_room { m::room_id(string_view(webhook_room)) }; const auto repo { github_repopath(content) }; github_flow_for_each(repo, [&] (const json::object &flow) { const json::string name { flow["name"] }; const json::string id { flow["id"] }; const fmt::bsprintf<128> key { "▶️ %s (%s)", name, id }; const auto annote_id { m::annotate(_webhook_room, _webhook_user, push_event_id, string_view(key)) }; return true; }); return true; } bool github_handle__pull_request(std::ostream &out, const json::object &content) { const json::object pr { content["pull_request"] }; if(pr["merged"] != "true") out << " " << "" << json::string(content["action"]) << "" ; if(pr["title"]) out << " " << "" << json::string(pr["title"]) << "" << " " << ' ' ; const json::object head { pr["head"] }; const json::object base { pr["base"] }; for(const json::object label : json::array(pr["labels"])) { out << " "; out << ""; out << ""; out << " "; out << json::string(label["name"]); out << " "; out << ""; out << ""; } if(pr["merged"] == "true") out << ' ' << "" << " " << "merged" << " " << "" ; if(pr.has("merged_by") && pr["merged_by"] != "null") { const json::object merged_by{pr["merged_by"]}; out << " " << "by " << "" << json::string(merged_by["login"]) << "" ; } const json::string &body { pr["body"] }; if(!empty(body)) out << ' ' << "
"
		    << body
		    << "
" << ' ' ; else out << ' ' << "
" ; if(pr.has("commits")) out << ' ' << " " << "" << pr["commits"] << ' ' << "" << "commits" << "" << "" ; if(pr.has("comments")) out << ' ' << " " << "" << pr["comments"] << ' ' << "" << "comments" << "" << "" ; if(pr.has("changed_files")) out << ' ' << " " << "" << pr["changed_files"] << ' ' << "" << "files" << "" << "" ; if(pr.has("additions")) out << ' ' << " " << "" << "" << "++" << "" << pr["additions"] << "" ; if(pr.has("deletions")) out << ' ' << "" << "" << "--" << "" << pr["deletions"] << "" ; if(pr["merged"] == "false") switch(hash(pr["mergeable"])) { default: case "null"_: break; case "true"_: out << ' ' << "" << "" << " " << "NO CONFLICTS" << " " << "" << "" ; break; case "false"_: out << ' ' << "" << "" << " " << "MERGE CONFLICT" << " " << "" << "" ; break; } return true; } bool github_handle__issues(std::ostream &out, const json::object &content) { const json::string &action { content["action"] }; out << " " << "" << action << "" ; const json::object issue { content["issue"] }; switch(hash(action)) { case "assigned"_: case "unassigned"_: { const json::object assignee { content["assignee"] }; out << " " << "" << json::string(assignee["login"]) << "" ; break; } } out << " " << "" << "" << json::string(issue["title"]) << "" << "" ; for(const json::object label : json::array(issue["labels"])) { out << " "; out << ""; out << ""; out << " "; out << json::string(label["name"]); out << " "; out << ""; out << ""; } if(action == "opened") { out << " " << "
" << "
"
		    ;

		static const auto delim("\\r\\n");
		const json::string body(issue["body"]);
		ircd::tokens(body, delim, [&out]
		(const string_view &line)
		{
			out << line << "
"; }); out << "" << "
" << "
" ; } else if(action == "labeled") { // quiet these messages for now until we can figure out how to reduce // noise around issue opens. return false; const json::object label { content["label"] }; out << "
    "; out << "
  • added: "; out << ""; out << ""; out << " "; out << json::string(label["name"]); out << " "; out << ""; out << ""; out << "
  • "; out << "
"; } else if(action == "unlabeled") { // quiet these messages for now until we can figure out how to reduce // noise around issue opens. return false; const json::object label { content["label"] }; out << "
    "; out << "
  • removed: "; out << ""; out << ""; out << " "; out << json::string(label["name"]); out << " "; out << ""; out << ""; out << "
  • "; out << "
"; } else if(action == "milestoned") { const json::object &milestone { content["milestone"] }; out << "
    " << "
  • " << "" << json::string(milestone["title"]) << "" << ' ' ; const json::string &state{milestone["state"]}; if(state == "open") out << "" ; else if(state == "closed") out << "" ; out << " " << state << " " << "" ; out << ' ' << " " << "Issues" << ' ' << "" << "" << milestone["open_issues"] << "" << "" << ' ' << "open" << ' ' << "" << "" << milestone["closed_issues"] << "" << "" << ' ' << "closed" << "
  • " << "
" ; } return true; } bool github_handle__issue_comment(std::ostream &out, const json::object &content) { const json::object issue { content["issue"] }; const json::object comment { content["comment"] }; const json::string action { content["action"] }; out << " "; switch(hash(action)) { case "created"_: out << "commented on"; break; default: out << action; break; } out << ""; out << " " << "" << "" << json::string(issue["title"]) << "" << "" ; if(action == "created") { out << " " << "
" << "
"
		    ;

		static const auto delim("\\r\\n");
		const json::string body(comment["body"]);
		ircd::tokens(body, delim, [&out]
		(const string_view &line)
		{
			out << line << "
"; }); out << "" << "
" << "
" ; } for(const json::object label : json::array(issue["labels"])) out << "" << "" << " " << json::string(label["name"]) << " " << "" << "" << " " ; return true; } bool github_handle__commit_comment(std::ostream &out, const json::object &content) { const json::object comment { content["comment"] }; const json::string action { content["action"] }; const json::string commit { comment["commit_id"] }; const json::string assoc { comment["author_association"] }; char assoc_buf[32]; if(assoc && assoc != "NONE") out << " [" << tolower(assoc_buf, assoc) << "]" ; out << " "; switch(hash(action)) { case "created"_: out << "commented on"; break; default: out << action; break; } out << ""; out << " " << "" << "" << trunc(commit, 8) << "" << "" ; if(action == "created") { out << " " << "
" ; const json::string body { comment["body"] }; static const auto delim("\\r\\n"); ircd::tokens(body, delim, [&out] (const string_view &line) { out << line << "
"; }); out << "" << "
" ; } return true; } bool github_handle__label(std::ostream &out, const json::object &content) { const json::string &action { content["action"] }; out << " " << "" << action << "" ; const json::object &label { content["label"] }; out << "
    "; out << "
  • "; out << ""; out << ""; out << "   "; out << json::string(label["name"]); out << "   "; out << ""; out << ""; out << "
  • "; out << "
"; if(action == "edited") { const json::object &changes { content["changes"] }; const json::object &color_obj { changes["color"] }; const json::string &color { empty(color_obj)? label["color"]: color_obj["from"] }; const json::object &name_obj { changes["name"] }; const json::string &name { empty(name_obj)? label["name"]: name_obj["from"] }; out << "from: "; out << "
    "; out << "
  • "; out << ""; out << ""; out << "   "; out << name; out << "   "; out << ""; out << ""; out << "
  • "; out << "
"; } return true; } bool github_handle__organization(std::ostream &out, const json::object &content) { const json::string &action { content["action"] }; const auto &action_words { split(action, '_') }; out << " " << ""; if(action_words.second) out << split(action, '_').second << " "; out << action_words.first << ""; if(action == "member_added") { const json::object &membership { content["membership"] }; const json::object &user { membership["user"] }; out << " " << "" << json::string(user["login"]) << "" ; out << " with role " << json::string(membership["role"]) ; } else if(action == "member_removed") { const json::object &membership { content["membership"] }; const json::object &user { membership["user"] }; out << " " << "" << json::string(user["login"]) << "" ; } else if(action == "member_invited") { const json::object &invitation { content["invitation"] }; const json::object &user { invitation["user"] }; out << " " << "" << json::string(user["login"]) << "" ; } return true; } bool github_handle__status(std::ostream &out, const json::object &content) { const m::user::id::buf _webhook_user { string_view{webhook_user}, my_host() }; const auto _webhook_room_id { m::room_id(string_view(webhook_room)) }; const m::room _webhook_room { _webhook_room_id }; const json::string &state { content["state"] }; const json::string &commit_hash { content["sha"] }; const auto push_event_id { github_find_push_event_id(_webhook_room, _webhook_user, commit_hash) }; if(push_event_id) switch(hash(state)) { case "error"_: m::annotate(_webhook_room, _webhook_user, push_event_id, "⭕"); break; case "failure"_: m::annotate(_webhook_room, _webhook_user, push_event_id, "🔴"); break; case "pending"_: m::annotate(_webhook_room, _webhook_user, push_event_id, "🟡"); break; case "success"_: m::annotate(_webhook_room, _webhook_user, push_event_id, "🟢"); break; } if(!webhook_status_verbose) switch(hash(state)) { case "error"_: return false; case "failure"_: break; case "pending"_: return false; case "success"_: return false; default: return false; } const json::string &description { content["description"] }; const string_view &url { content["target_url"] }; if(state == "success") out << " " << "" ; else if(state == "failure") out << " " << "" ; else if(state == "error") out << " " << "" ; out << " " << ""; out << "" << "" << "" << description << "" << "" << "" << " " << "" ; return true; } bool github_handle__watch(std::ostream &out, const json::object &content) { const json::string &action { content["action"] }; if(action != "started") return false; // There appears to be no way to distinguish between a genuine watch // button click and just a star; the watch event is sent for both. // Returning false just disables this event so there's no double-message. return false; } bool github_handle__star(std::ostream &out, const json::object &content) { const json::string &action { content["action"] }; if(action != "created") return false; return true; } bool github_handle__repository(std::ostream &out, const json::object &content) { const json::string &action { content["action"] }; out << ' ' << action; out << "
"
	    << json::string(content["description"])
	    << "
"; return true; } bool github_handle__create(std::ostream &out, const json::object &content) { const json::string &ref { content["ref"] }; const json::string &ref_type { content["ref_type"] }; out << ' ' << ref_type << ' ' << "" << ref << "" ; if(ref_type == "tag") out << ' ' << "🎉"; return true; } bool github_handle__delete(std::ostream &out, const json::object &content) { const json::string &ref { content["ref"] }; const json::string &ref_type { content["ref_type"] }; out << ' ' << ref_type << ' ' << "" << ref << "" ; return true; } bool github_handle__ping(std::ostream &out, const json::object &content) { out << "
"
	    << json::string(content["zen"])
	    << "
"; return true; } // Find the message resulting from the push and react with the status. ircd::m::event::id::buf github_find_push_event_id(const m::room &room, const m::user::id &user_id, const string_view &commit_hash) { const auto type_match { [](const string_view &type) noexcept { return type == "m.room.message"; } }; const auto user_match { [&user_id](const string_view &sender) noexcept { return sender && sender == user_id; } }; const auto content_match { [&commit_hash](const json::object &content) { const json::string &body { content["body"] }; return has(body, "push") && has(body, commit_hash); } }; // Limit the search to a maximum of recent messages from the // webhook user and total messages so we don't run out of control // and scan the whole room history. int lim[2] { 768, 384 }; m::room::events it{room}; for(; it && lim[0] > 0 && lim[1] > 0; --it, --lim[0]) { if(!m::query(std::nothrow, it.event_idx(), "sender", user_match)) continue; --lim[1]; if(!m::query(std::nothrow, it.event_idx(), "type", type_match)) continue; if(!m::query(std::nothrow, it.event_idx(), "content", content_match)) continue; return m::event_id(std::nothrow, it.event_idx()); } return {}; } std::pair github_find_repo(const json::object &content) { const json::object repository { content["repository"] }; if(!empty(repository)) return { repository["full_name"], repository["html_url"] }; const json::object organization { content["organization"] }; return { organization["login"], organization["url"] }; } /// Researched from yestifico bot std::pair github_find_party(const json::object &content) { const json::object pull_request { content["pull_request"] }; const json::object user { pull_request["user"] }; if(!empty(user)) return { user["login"], user["html_url"] }; const json::object sender { content["sender"] }; return { sender["login"], sender["html_url"] }; } /// Researched from yestifico bot json::string github_find_issue_number(const json::object &content) { const json::object issue(content["issue"]); if(!empty(issue)) return issue["number"]; if(content["number"]) return content["number"]; return {}; } /// Researched from yestifico bot json::string github_find_commit_hash(const json::object &content) { if(content["sha"]) return content["sha"]; const json::object commit(content["commit"]); if(!empty(commit)) return commit["sha"]; const json::object head(content["head"]); if(!empty(head)) return head["commit"]; const json::object head_commit(content["head_commit"]); if(!empty(head_commit)) return head_commit["id"]; const json::object comment(content["comment"]); if(!empty(comment)) return comment["commit_id"]; if(content["commit"]) return content["commit"]; const json::object pr{content["pull_request"]}; const json::object prhead{pr["head"]}; if(prhead["sha"]) return prhead["sha"]; const json::object workflow_run{content["workflow_run"]}; if(workflow_run["head_sha"]) return workflow_run["head_sha"]; const json::object workflow_job{content["workflow_job"]}; if(workflow_job["head_sha"]) return workflow_job["head_sha"]; const json::object check_run{content["check_run"]}; if(check_run["head_sha"]) return check_run["head_sha"]; const json::object check_suite{content["check_suite"]}; if(check_suite["head_sha"]) return check_suite["head_sha"]; return {}; } std::string github_url(const json::string &url) { std::string base("https://"); return base + std::string(lstrip(url, "https://api.")); } bool github_validate(const string_view &sigheader, const const_buffer &content, const string_view &secret) try { const auto sig { split(sigheader, "=") }; crh::hmac hmac { sig.first, secret }; hmac.update(content); char ubuf[64], abuf[sizeof(ubuf) * 2]; if(unlikely(sizeof(ubuf) < hmac.length())) throw ircd::panic { "HMAC algorithm '%s' digest exceeds buffer size.", sig.first }; const const_buffer hmac_bin { hmac.finalize(ubuf) }; static_assert(sizeof(abuf) >= sizeof(ubuf) * 2); const string_view hmac_hex { u2a(abuf, hmac_bin) }; return hmac_hex == sig.second; } catch(const crh::error &e) { throw http::error { http::NOT_IMPLEMENTED, "The signature algorithm is not supported.", }; } /////////////////////////////////////////////////////////////////////////////// // // appveyor // void appveyor_handle(client &client, const resource::request &request) { const http::headers &headers { request.head.headers }; } /////////////////////////////////////////////////////////////////////////////// // // dockerhub // static bool dockerhub_handle_push(std::ostream &out, std::ostream &alt, client &client, const resource::request &request); void dockerhub_handle(client &client, const resource::request &request) try { if(!string_view(webhook_room)) return; if(!string_view(webhook_user)) return; const auto callback_url { request["callback_url"] }; const unique_buffer buf[2] { { 48_KiB }, { 4_KiB }, }; std::stringstream out, alt; pubsetbuf(out, buf[0]); pubsetbuf(alt, buf[1]); bool output {true}; if(request.has("push_data")) output = dockerhub_handle_push(out, alt, client, request); const auto room_id { m::room_id(string_view(webhook_room)) }; const m::user::id::buf user_id { string_view(webhook_user), my_host() }; const auto msg { view(out, buf[0]) }; const auto alt_msg { view(alt, buf[1]) }; const auto evid { output? m::msghtml(room_id, user_id, msg, alt_msg, "m.notice"): m::event::id::buf{} }; log::info { "Webhook '%s' delivered to %s %s", "push"_sv, string_view{room_id}, string_view{evid}, }; } catch(const std::exception &e) { log::error { "dockerhub webhook :%s", e.what(), }; throw; } bool dockerhub_handle_push(std::ostream &out, std::ostream &alt, client &client, const resource::request &request) { const json::object push_data { request["push_data"] }; const json::object repository { request["repository"] }; const json::string pusher { push_data["pusher"] }; const string_view pushed_at { push_data["pushed_at"] }; const json::string tag { push_data["tag"] }; const json::array images { push_data["images"] }; out << "" << json::string(repository["repo_name"]) << " push by " << pusher << " to " << tag << "" ; alt << json::string(repository["repo_name"]) << " push by " << pusher << " to " << tag ; return bool(webhook_status_verbose); }