diff options
author | Francois Kritzinger <francois@codesynthesis.com> | 2024-04-17 11:09:46 +0200 |
---|---|---|
committer | Francois Kritzinger <francois@codesynthesis.com> | 2024-06-05 09:12:46 +0200 |
commit | 3f2a23ab62b5cbb940db7f925b3fff84d688285f (patch) | |
tree | f9340d9efac04cd4b884427a0b19c63b3e938c89 /mod | |
parent | c92142ce729c5ef38b25465dd2e7c2d67df05172 (diff) |
Get restructure to compile
Diffstat (limited to 'mod')
-rw-r--r-- | mod/mod-ci-github-gh.cxx | 349 | ||||
-rw-r--r-- | mod/mod-ci-github-gh.hxx | 115 | ||||
-rw-r--r-- | mod/mod-ci-github-gq.cxx (renamed from mod/mod-ci-github-qg.cxx) | 625 | ||||
-rw-r--r-- | mod/mod-ci-github-gq.hxx | 233 | ||||
-rw-r--r-- | mod/mod-ci-github-post.hxx | 4 | ||||
-rw-r--r-- | mod/mod-ci-github-service-data.cxx | 150 | ||||
-rw-r--r-- | mod/mod-ci-github-service-data.hxx | 29 | ||||
-rw-r--r-- | mod/mod-ci-github.cxx | 561 | ||||
-rw-r--r-- | mod/mod-ci-github.hxx | 6 |
9 files changed, 1105 insertions, 967 deletions
diff --git a/mod/mod-ci-github-gh.cxx b/mod/mod-ci-github-gh.cxx new file mode 100644 index 0000000..dc1447f --- /dev/null +++ b/mod/mod-ci-github-gh.cxx @@ -0,0 +1,349 @@ +// file : mod/mod-ci-github-gh.cxx -*- C++ -*- +// license : MIT; see accompanying LICENSE file + +#include <mod/mod-ci-github-gh.hxx> + +#include <libbutl/json/parser.hxx> + +namespace brep +{ + static const string gh_status[] {"QUEUED", "IN_PROGRESS", "COMPLETED"}; + + // Return the GitHub check run status corresponding to a build_state. + // + string + gh_to_status (build_state st) + { + // @@ Just return by value (small string optimization). + // + // @@ TMP Keep this comment, right? + // + return gh_status[static_cast<size_t> (st)]; + } + + // Return the build_state corresponding to a GitHub check run status + // string. Throw invalid_argument if the passed status was invalid. + // + build_state + gh_from_status (const string& s) + { + if (s == "QUEUED") return build_state::queued; + else if (s == "IN_PROGRESS") return build_state::building; + else if (s == "COMPLETED") return build_state::built; + else + throw invalid_argument ("invalid GitHub check run status: '" + s + + '\''); + } + + string + gh_check_run_name (const build& b, + const tenant_service_base::build_hints* bh) + { + string r; + + if (bh == nullptr || !bh->single_package_version) + { + r += b.package_name.string (); + r += '/'; + r += b.package_version.string (); + r += '/'; + } + + r += b.target_config_name; + r += '/'; + r += b.target.string (); + r += '/'; + + if (bh == nullptr || !bh->single_package_config) + { + r += b.package_config_name; + r += '/'; + } + + r += b.toolchain_name; + r += '-'; + r += b.toolchain_version.string (); + + return r; + } + + // Throw invalid_json_input when a required member `m` is missing from a + // JSON object `o`. + // + [[noreturn]] static void + missing_member (const json::parser& p, const char* o, const char* m) + { + throw json::invalid_json_input ( + p.input_name, + p.line (), p.column (), p.position (), + o + string (" object is missing member '") + m + '\''); + } + + using event = json::event; + + // gh_check_suite + // + gh_check_suite:: + gh_check_suite (json::parser& p) + { + p.next_expect (event::begin_object); + + bool ni (false), hb (false), hs (false), bf (false), at (false); + + // Skip unknown/uninteresting members. + // + while (p.next_expect (event::name, event::end_object)) + { + auto c = [&p] (bool& v, const char* s) + { + return p.name () == s ? (v = true) : false; + }; + + if (c (ni, "node_id")) node_id = p.next_expect_string (); + else if (c (hb, "head_branch")) head_branch = p.next_expect_string (); + else if (c (hs, "head_sha")) head_sha = p.next_expect_string (); + else if (c (bf, "before")) before = p.next_expect_string (); + else if (c (at, "after")) after = p.next_expect_string (); + else p.next_expect_value_skip (); + } + + if (!ni) missing_member (p, "gh_check_suite", "node_id"); + if (!hb) missing_member (p, "gh_check_suite", "head_branch"); + if (!hs) missing_member (p, "gh_check_suite", "head_sha"); + if (!bf) missing_member (p, "gh_check_suite", "before"); + if (!at) missing_member (p, "gh_check_suite", "after"); + } + + ostream& + operator<< (ostream& os, const gh_check_suite& cs) + { + os << "node_id: " << cs.node_id + << ", head_branch: " << cs.head_branch + << ", head_sha: " << cs.head_sha + << ", before: " << cs.before + << ", after: " << cs.after; + + return os; + } + + // gh_check_run + // + gh_check_run:: + gh_check_run (json::parser& p) + { + p.next_expect (event::begin_object); + + bool ni (false), nm (false), st (false); + + while (p.next_expect (event::name, event::end_object)) + { + auto c = [&p] (bool& v, const char* s) + { + return p.name () == s ? (v = true) : false; + }; + + if (c (ni, "id")) node_id = p.next_expect_string (); + else if (c (nm, "name")) name = p.next_expect_string (); + else if (c (st, "status")) status = p.next_expect_string (); + } + + if (!ni) missing_member (p, "gh_check_run", "id"); + if (!nm) missing_member (p, "gh_check_run", "name"); + if (!st) missing_member (p, "gh_check_run", "status"); + } + + ostream& + operator<< (ostream& os, const gh_check_run& cr) + { + os << "node_id: " << cr.node_id + << ", name: " << cr.name + << ", status: " << cr.status; + + return os; + } + + // gh_repository + // + gh_repository:: + gh_repository (json::parser& p) + { + p.next_expect (event::begin_object); + + bool ni (false), nm (false), fn (false), db (false), cu (false); + + // Skip unknown/uninteresting members. + // + while (p.next_expect (event::name, event::end_object)) + { + auto c = [&p] (bool& v, const char* s) + { + return p.name () == s ? (v = true) : false; + }; + + if (c (ni, "node_id")) node_id = p.next_expect_string (); + else if (c (nm, "name")) name = p.next_expect_string (); + else if (c (fn, "full_name")) full_name = p.next_expect_string (); + else if (c (db, "default_branch")) default_branch = p.next_expect_string (); + else if (c (cu, "clone_url")) clone_url = p.next_expect_string (); + else p.next_expect_value_skip (); + } + + if (!ni) missing_member (p, "gh_repository", "node_id"); + if (!nm) missing_member (p, "gh_repository", "name"); + if (!fn) missing_member (p, "gh_repository", "full_name"); + if (!db) missing_member (p, "gh_repository", "default_branch"); + if (!cu) missing_member (p, "gh_repository", "clone_url"); + } + + ostream& + operator<< (ostream& os, const gh_repository& rep) + { + os << "node_id: " << rep.node_id + << ", name: " << rep.name + << ", full_name: " << rep.full_name + << ", default_branch: " << rep.default_branch + << ", clone_url: " << rep.clone_url; + + return os; + } + + // gh_installation + // + gh_installation:: + gh_installation (json::parser& p) + { + p.next_expect (event::begin_object); + + bool i (false); + + // Skip unknown/uninteresting members. + // + while (p.next_expect (event::name, event::end_object)) + { + auto c = [&p] (bool& v, const char* s) + { + return p.name () == s ? (v = true) : false; + }; + + if (c (i, "id")) id = p.next_expect_number<uint64_t> (); + else p.next_expect_value_skip (); + } + + if (!i) missing_member (p, "gh_installation", "id"); + } + + ostream& + operator<< (ostream& os, const gh_installation& i) + { + os << "id: " << i.id; + + return os; + } + + // gh_check_suite_event + // + gh_check_suite_event:: + gh_check_suite_event (json::parser& p) + { + p.next_expect (event::begin_object); + + bool ac (false), cs (false), rp (false), in (false); + + // Skip unknown/uninteresting members. + // + while (p.next_expect (event::name, event::end_object)) + { + auto c = [&p] (bool& v, const char* s) + { + return p.name () == s ? (v = true) : false; + }; + + if (c (ac, "action")) action = p.next_expect_string (); + else if (c (cs, "check_suite")) check_suite = gh_check_suite (p); + else if (c (rp, "repository")) repository = gh_repository (p); + else if (c (in, "installation")) installation = gh_installation (p); + else p.next_expect_value_skip (); + } + + if (!ac) missing_member (p, "gh_check_suite_event", "action"); + if (!cs) missing_member (p, "gh_check_suite_event", "check_suite"); + if (!rp) missing_member (p, "gh_check_suite_event", "repository"); + if (!in) missing_member (p, "gh_check_suite_event", "installation"); + } + + ostream& + operator<< (ostream& os, const gh_check_suite_event& cs) + { + os << "action: " << cs.action; + os << ", check_suite { " << cs.check_suite << " }"; + os << ", repository { " << cs.repository << " }"; + os << ", installation { " << cs.installation << " }"; + + return os; + } + + // gh_installation_access_token + // + // Example JSON: + // + // { + // "token": "ghs_Py7TPcsmsITeVCAWeVtD8RQs8eSos71O5Nzp", + // "expires_at": "2024-02-15T16:16:38Z", + // ... + // } + // + gh_installation_access_token:: + gh_installation_access_token (json::parser& p) + { + p.next_expect (event::begin_object); + + bool tk (false), ea (false); + + // Skip unknown/uninteresting members. + // + while (p.next_expect (event::name, event::end_object)) + { + auto c = [&p] (bool& v, const char* s) + { + return p.name () == s ? (v = true) : false; + }; + + if (c (tk, "token")) token = p.next_expect_string (); + else if (c (ea, "expires_at")) expires_at = from_iso8601 (p.next_expect_string ()); + else p.next_expect_value_skip (); + } + + if (!tk) missing_member (p, "gh_installation_access_token", "token"); + if (!ea) missing_member (p, "gh_installation_access_token", "expires_at"); + } + + gh_installation_access_token:: + gh_installation_access_token (string tk, timestamp ea) + : token (move (tk)), expires_at (ea) + { + } + + ostream& + operator<< (ostream& os, const gh_installation_access_token& t) + { + os << "token: " << t.token << ", expires_at: "; + butl::operator<< (os, t.expires_at); + + return os; + } + + string + to_iso8601 (timestamp t) + { + return butl::to_string (t, + "%Y-%m-%dT%TZ", + false /* special */, + false /* local */); + } + + timestamp + from_iso8601 (const string& s) + { + return butl::from_string (s.c_str (), "%Y-%m-%dT%TZ", false /* local */); + } +} diff --git a/mod/mod-ci-github-gh.hxx b/mod/mod-ci-github-gh.hxx index 23ad247..c7ac00c 100644 --- a/mod/mod-ci-github-gh.hxx +++ b/mod/mod-ci-github-gh.hxx @@ -7,6 +7,10 @@ #include <libbrep/types.hxx> #include <libbrep/utility.hxx> +#include <libbrep/build.hxx> + +#include <mod/tenant-service.hxx> // build_hints + namespace butl { namespace json @@ -45,85 +49,42 @@ namespace brep string after; explicit - check_suite (json::parser&); + gh_check_suite (json::parser&); - check_suite () = default; + gh_check_suite () = default; }; - struct check_run + struct gh_check_run { string node_id; string name; string status; explicit - check_run (json::parser&); + gh_check_run (json::parser&); - check_run () = default; + gh_check_run () = default; }; // Return the GitHub check run status corresponding to a build_state. // string - gh_to_status (build_state st) - { - // @@ Just return by value (small string optimization). - // - static const string sts[] {"QUEUED", "IN_PROGRESS", "COMPLETED"}; - - return sts[static_cast<size_t> (st)]; - } + gh_to_status (build_state st); // Return the build_state corresponding to a GitHub check run status // string. Throw invalid_argument if the passed status was invalid. // build_state - gh_from_status (const string& s) - { - if (s == "QUEUED") return build_state::queued; - else if (s == "IN_PROGRESS") return build_state::building; - else if (s == "COMPLETED") return build_state::built; - else - throw invalid_argument ("invalid GitHub check run status: '" + s + - '\''); - } + gh_from_status (const string&); // Create a check_run name from a build. If the second argument is not // NULL, return an abbreviated id if possible. // string - gh_check_run_name (const build& b, - const tenant_service_base::build_hints* bh = nullptr) - { - string r; - - if (bh == nullptr || !bh->single_package_version) - { - r += b.package_name.string (); - r += '/'; - r += b.package_version.string (); - r += '/'; - } - - r += b.target_config_name; - r += '/'; - r += b.target.string (); - r += '/'; - - if (bh == nullptr || !bh->single_package_config) - { - r += b.package_config_name; - r += '/'; - } - - r += b.toolchain_name; - r += '-'; - r += b.toolchain_version.string (); - - return r; - } + gh_check_run_name (const build&, + const tenant_service_base::build_hints* = nullptr); - struct repository + struct gh_repository { string node_id; string name; @@ -132,66 +93,72 @@ namespace brep string clone_url; explicit - repository (json::parser&); + gh_repository (json::parser&); - repository () = default; + gh_repository () = default; }; - struct installation + struct gh_installation { uint64_t id; // Note: used for installation access token (REST API). explicit - installation (json::parser&); + gh_installation (json::parser&); - installation () = default; + gh_installation () = default; }; // The check_suite webhook event request. // - struct check_suite_event + struct gh_check_suite_event { string action; - gh::check_suite check_suite; - gh::repository repository; - gh::installation installation; + gh_check_suite check_suite; + gh_repository repository; + gh_installation installation; explicit - check_suite_event (json::parser&); + gh_check_suite_event (json::parser&); - check_suite_event () = default; + gh_check_suite_event () = default; }; - struct installation_access_token + struct gh_installation_access_token { string token; timestamp expires_at; explicit - installation_access_token (json::parser&); + gh_installation_access_token (json::parser&); - installation_access_token (string token, timestamp expires_at); + gh_installation_access_token (string token, timestamp expires_at); - installation_access_token () = default; + gh_installation_access_token () = default; }; ostream& - operator<< (ostream&, const check_suite&); + operator<< (ostream&, const gh_check_suite&); ostream& - operator<< (ostream&, const check_run&); + operator<< (ostream&, const gh_check_run&); ostream& - operator<< (ostream&, const repository&); + operator<< (ostream&, const gh_repository&); ostream& - operator<< (ostream&, const installation&); + operator<< (ostream&, const gh_installation&); ostream& - operator<< (ostream&, const check_suite_event&); + operator<< (ostream&, const gh_check_suite_event&); ostream& - operator<< (ostream&, const installation_access_token&); + operator<< (ostream&, const gh_installation_access_token&); + + string + to_iso8601 (timestamp); + + timestamp + from_iso8601 (const string&); } #endif // MOD_MOD_CI_GITHUB_GH_HXX diff --git a/mod/mod-ci-github-qg.cxx b/mod/mod-ci-github-gq.cxx index b0e40a6..4fe9190 100644 --- a/mod/mod-ci-github-qg.cxx +++ b/mod/mod-ci-github-gq.cxx @@ -3,6 +3,14 @@ #include <mod/mod-ci-github-gq.hxx> +#include <libbutl/json/parser.hxx> +#include <libbutl/json/serializer.hxx> + +#include <mod/mod-ci-github-post.hxx> + +using namespace std; +using namespace butl; + namespace brep { // GraphQL serialization functions (see definitions and documentation at the @@ -13,20 +21,191 @@ namespace brep static string gq_bool (bool); static const string& gq_enum (const string&); + [[noreturn]] static void + throw_json (json::parser& p, const string& m) + { + throw json::invalid_json_input ( + p.input_name, + p.line (), p.column (), p.position (), + m); + } + + // Parse a JSON-serialized GraphQL response. + // + // Throw runtime_error if the response indicated errors and + // invalid_json_input if the GitHub response contained invalid JSON. + // + // The response format is defined in the GraphQL spec: + // https://spec.graphql.org/October2021/#sec-Response. + // + // Example response: + // + // { + // "data": {...}, + // "errors": {...} + // } + // + // The contents of `data`, including its opening and closing braces, are + // parsed by the `parse_data` function. + // + // @@ TODO: specify what parse_data may throw (probably only + // invalid_json_input). + // + // @@ TODO errors comes before data in GitHub's responses. + // + static void + gq_parse_response (json::parser& p, + function<void (json::parser&)> parse_data) + { + using event = json::event; + + // True if the data/errors fields are present. + // + // Although the spec merely recommends that the `errors` field, if + // present, comes before the `data` field, assume it always does because + // letting the client parse data in the presence of field errors + // (unexpected nulls) would not make sense. + // + bool dat (false), err (false); + + p.next_expect (event::begin_object); + + while (p.next_expect (event::name, event::end_object)) + { + if (p.name () == "data") + { + dat = true; + + // Currently we're not handling fields that are null due to field + // errors (see below for details) so don't parse any further. + // + if (err) + break; + + parse_data (p); + } + else if (p.name () == "errors") + { + // Don't stop parsing because the error semantics depends on whether + // or not `data` is present. + // + err = true; // Handled below. + } + else + { + // The spec says the response will never contain any top-level fields + // other than data, errors, and extensions. + // + if (p.name () != "extensions") + { + throw_json (p, + "unexpected top-level GraphQL response field: '" + + p.name () + '\''); + } + + p.next_expect_value_skip (); + } + } + + // If the `errors` field was present in the response, error(s) occurred + // before or during execution of the operation. + // + // If the `data` field was not present the errors are request errors which + // occur before execution and are typically the client's fault. + // + // If the `data` field was also present in the response the errors are + // field errors which occur during execution and are typically the GraphQL + // endpoint's fault, and some fields in `data` that should not be are + // likely to be null. + // + if (err) + { + if (dat) + { + // @@ TODO: Consider parsing partial data? + // + throw runtime_error ("field error(s) received from GraphQL endpoint; " + "incomplete data received"); + } + else + throw runtime_error ("request error(s) received from GraphQL endpoint"); + } + } + + // Parse a response to a check_run GraphQL mutation such as `createCheckRun` + // or `updateCheckRun`. + // + // Example response (only the part we need to parse here): + // + // { + // "cr0": { + // "checkRun": { + // "id": "CR_kwDOLc8CoM8AAAAFQ5GqPg", + // "name": "libb2/0.98.1+2/x86_64-linux-gnu/linux_debian_12-gcc_13.1-O3/default/dev/0.17.0-a.1", + // "status": "QUEUED" + // } + // }, + // "cr1": { + // "checkRun": { + // "id": "CR_kwDOLc8CoM8AAAAFQ5GqhQ", + // "name": "libb2/0.98.1+2/x86_64-linux-gnu/linux_debian_12-gcc_13.1/default/dev/0.17.0-a.1", + // "status": "QUEUED" + // } + // } + // } + // + // @@ TODO Handle response errors properly. + // + static vector<gh_check_run> + gq_parse_response_check_runs (json::parser& p) + { + using event = json::event; + + vector<gh_check_run> r; + + gq_parse_response (p, [&r] (json::parser& p) + { + p.next_expect (event::begin_object); + + // Parse the "cr0".."crN" members (field aliases). + // + while (p.next_expect (event::name, event::end_object)) + { + // Parse `"crN": { "checkRun":`. + // + if (p.name () != "cr" + to_string (r.size ())) + throw_json (p, "unexpected field alias: '" + p.name () + '\''); + p.next_expect (event::begin_object); + p.next_expect_name ("checkRun"); + + r.emplace_back (p); // Parse the check_run object. + + p.next_expect (event::end_object); // Parse end of crN object. + } + }); + + // Our requests always operate on at least one check run so if there were + // none in the data field something went wrong. + // + if (r.empty ()) + throw_json (p, "data object is empty"); + + return r; + } + // Send a GraphQL mutation request `rq` that operates on one or more check // runs. Update the check runs in `crs` with the new state and the node ID - // if unset (note: both fields are optionals). Return false and issue - // diagnostics if the request failed. + // if unset. Return false and issue diagnostics if the request failed. // static bool - gq_mutate_check_runs (vector<service_data::check_run>& crs, - const vector<reference_wrapper<const build>>& bs, + gq_mutate_check_runs (vector<check_run>& crs, const string& iat, + const vector<reference_wrapper<const build>>& bs, string rq, build_state st, const basic_mark& error) noexcept { - vector<check_run> rcrs; + vector<gh_check_run> rcrs; try { @@ -35,9 +214,9 @@ namespace brep // struct resp { - vector<check_run> check_runs; // Received check runs. + vector<gh_check_run> check_runs; // Received check runs. - resp (json::parser& p) : check_runs (parse_check_runs_response (p)) {} + resp (json::parser& p) : check_runs (gq_parse_response_check_runs (p)) {} resp () = default; } rs; @@ -57,26 +236,26 @@ namespace brep { // Validate the check run in the response against the build. // - const check_run& rcr (rcrs[i]); // Received check run. + const gh_check_run& rcr (rcrs[i]); // Received check run. const build& b (bs[i]); - build_state rst (from_string_gh (rcr.status)); // Received state. + build_state rst (gh_from_status (rcr.status)); // Received state. if (rst != build_state::built && rst != st) { error << "unexpected check_run status: received '" << rcr.status - << "' but expected '" << to_string_gh (st) << '\''; + << "' but expected '" << gh_to_status (st) << '\''; return false; // Fail because something is clearly very wrong. } else { - service_data::check_run& cr (crs[i]); + check_run& cr (crs[i]); if (!cr.node_id) cr.node_id = move (rcr.node_id); - cr.state = from_string_gh (rcr.status); + cr.state = gh_from_status (rcr.status); } } @@ -106,7 +285,7 @@ namespace brep error << "unable to mutate check runs (errno=" << e.code () << "): " << e.what (); } - catch (const runtime_error& e) // From parse_check_runs_response(). + catch (const runtime_error& e) // From gq_parse_response_check_runs(). { // GitHub response contained error(s) (could be ours or theirs at this // point). @@ -117,31 +296,35 @@ namespace brep return false; } - static bool - gq_mutate_check_run (service_data::check_run& cr, - const vector<reference_wrapper<const build>>& bs, - const string& iat, - string rq, - build_state st, - const basic_mark& error) noexcept + // Serialize a GraphQL operation (query/mutation) into a GraphQL request. + // + // This is essentially a JSON object with a "query" string member containing + // the GraphQL operation. For example: + // + // { "query": "mutation { cr0:createCheckRun(... }" } + // + static string + gq_serialize_request (const string& o) { - vector<service_data::check_run> crs {move (cr)}; - - bool r (mutate_check_runs (crs, bs, iat, move (rq), st, error)); + string b; + json::buffer_serializer s (b); - cr = move (crs[0]); + s.begin_object (); + s.member ("query", o); + s.end_object (); - return r; + return b; } // Serialize `createCheckRun` mutations for one or more builds to GraphQL. // static string - gq_create_check_runs (const string& ri, // Repository ID - const string& hs, // Head SHA - const vector<reference_wrapper<const build>>& bs, - build_state st, - const tenant_service_base::build_hints* bh = nullptr) + gq_mutation_create_check_runs ( + const string& ri, // Repository ID + const string& hs, // Head SHA + const vector<reference_wrapper<const build>>& bs, + build_state st, + const tenant_service_base::build_hints* bh) { ostringstream os; @@ -157,13 +340,13 @@ namespace brep // Check run name. // - string nm (check_run_name (b, bh)); + string nm (gh_check_run_name (b, bh)); os << gq_name (al) << ":createCheckRun(input: {" << '\n' << " name: " << gq_str (nm) << ',' << '\n' << " repositoryId: " << gq_str (ri) << ',' << '\n' << " headSha: " << gq_str (hs) << ',' << '\n' - << " status: " << gq_enum (to_string_gh (st)) << '\n' + << " status: " << gq_enum (gh_to_status (st)) << '\n' << "})" << '\n' // Specify the selection set (fields to be returned). // @@ -186,17 +369,17 @@ namespace brep // @@ TODO Support conclusion, output, etc. // static string - gq_update_check_run (const string& ri, // Repository ID - const string& ci, // Check run node_id - build_state st) + gq_mutation_update_check_run (const string& ri, // Repository ID. + const string& ni, // Node ID. + build_state st) { ostringstream os; os << "mutation {" << '\n' << "cr0:updateCheckRun(input: {" << '\n' - << " checkRunId: " << gq_str (ci) << ',' << '\n' + << " checkRunId: " << gq_str (ni) << ',' << '\n' << " repositoryId: " << gq_str (ri) << ',' << '\n' - << " status: " << gq_enum (to_string_gh (st)) << '\n' + << " status: " << gq_enum (gh_to_status (st)) << '\n' << "})" << '\n' // Specify the selection set (fields to be returned). // @@ -212,198 +395,236 @@ namespace brep return os.str (); } - // Serialize a GraphQL operation (query/mutation) into a GraphQL request. - // - // This is essentially a JSON object with a "query" string member containing - // the GraphQL operation. For example: - // - // { "query": "mutation { cr0:createCheckRun(... }" } - // - static string - gq_serialize_request (const string& o) + bool + gq_create_check_runs (vector<check_run>& crs, + const string& iat, + const string& rid, + const string& hs, + const vector<reference_wrapper<const build>>& bs, + build_state st, + const tenant_service_base::build_hints& bh, + const basic_mark& error) { - string b; - json::buffer_serializer s (b); + string rq (gq_serialize_request ( + gq_mutation_create_check_runs (rid, hs, bs, st, &bh))); - s.begin_object (); - s.member ("query", o); - s.end_object (); - - return b; + return gq_mutate_check_runs (crs, iat, bs, move (rq), st, error); } - [[noreturn]] void - throw_json (json::parser& p, const string& m) + bool + gq_create_check_run (check_run& cr, + const string& iat, + const string& rid, + const string& hs, + const build& b, + build_state st, + const tenant_service_base::build_hints& bh, + const basic_mark& error) { - throw json::invalid_json_input ( - p.input_name, - p.line (), p.column (), p.position (), - m); + vector<check_run> crs {move (cr)}; + + bool r (gq_create_check_runs (crs, iat, rid, hs, {b}, st, bh, error)); + + cr = move (crs[0]); + + return r; } - // Parse a JSON-serialized GraphQL response. - // - // Throw runtime_error if the response indicated errors and - // invalid_json_input if the GitHub response contained invalid JSON. - // - // The response format is defined in the GraphQL spec: - // https://spec.graphql.org/October2021/#sec-Response. - // - // Example response: - // - // { - // "data": {...}, - // "errors": {...} - // } - // - // The contents of `data`, including its opening and closing braces, are - // parsed by the `parse_data` function. - // - // @@ TODO: specify what parse_data may throw (probably only - // invalid_json_input). - // - // @@ TODO errors comes before data in GitHub's responses. - // - static void - gq_parse_response (json::parser& p, - function<void (json::parser&)> parse_data) + bool + gq_update_check_run (check_run& cr, + const string& iat, + const string& rid, + const string& nid, + const build& b, + build_state st, + const basic_mark& error) { - using event = json::event; + string rq ( + gq_serialize_request (gq_mutation_update_check_run (rid, nid, st))); - // True if the data/errors fields are present. - // - // Although the spec merely recommends that the `errors` field, if - // present, comes before the `data` field, assume it always does because - // letting the client parse data in the presence of field errors - // (unexpected nulls) would not make sense. - // - bool dat (false), err (false); + vector<check_run> crs {move (cr)}; - p.next_expect (event::begin_object); + bool r (gq_mutate_check_runs (crs, iat, {b}, move (rq), st, error)); - while (p.next_expect (event::name, event::end_object)) - { - if (p.name () == "data") - { - dat = true; + cr = move (crs[0]); - // Currently we're not handling fields that are null due to field - // errors (see below for details) so don't parse any further. - // - if (err) - break; + return r; + } - parse_data (p); - } - else if (p.name () == "errors") + pair<optional<gh_check_run>, bool> + gq_fetch_check_run (const string& iat, + const string& check_suite_id, + const string& cr_name, + const basic_mark& error) noexcept + { + try + { + // Example request: + // + // query { + // node(id: "CS_kwDOLc8CoM8AAAAFQPQYEw") { + // ... on CheckSuite { + // checkRuns(last: 100, filterBy: {checkName: "linux_debian_..."}) { + // totalCount, + // edges { + // node { + // id, name, status + // } + // } + // } + // } + // } + // } + // + // This request does the following: + // + // - Look up the check suite by node ID ("direct node lookup"). This + // returns a Node (GraphQL interface). + // + // - Get to the concrete CheckSuite type by using a GraphQL "inline + // fragment" (`... on CheckSuite`). + // + // - Get the check suite's check runs + // - Filter by the sought name + // - Return only two check runs, just enough to be able to tell + // whether there are more than one check runs with this name (which + // is an error). + // + // - Return the id, name, and status fields from the matching check run + // objects. + // + string rq; { - // Don't stop parsing because the error semantics depends on whether - // or not `data` is present. - // - err = true; // Handled below. + ostringstream os; + + os << "query {" << '\n'; + + os << "node(id: " << gq_str (check_suite_id) << ") {" << '\n' + << " ... on CheckSuite {" << '\n' + << " checkRuns(last: 2," << '\n' + << " filterBy: {" << '\n' + << "checkName: " << gq_str (cr_name) << '\n' + << " })" << '\n' + // Specify the selection set (fields to be returned). Note that + // edges and node are mandatory. + // + << " {" << '\n' + << " totalCount," << '\n' + << " edges {" << '\n' + << " node {" << '\n' + << " id, name, status" << '\n' + << " }" << '\n' + << " }" << '\n' + << " }" << '\n' + << " }" << '\n' + << "}" << '\n'; + + os << "}" << '\n'; + + rq = os.str (); } - else + + // Example response (the part we need to parse here, at least): + // + // { + // "node": { + // "checkRuns": { + // "totalCount": 1, + // "edges": [ + // { + // "node": { + // "id": "CR_kwDOLc8CoM8AAAAFgeoweg", + // "name": "linux_debian_...", + // "status": "IN_PROGRESS" + // } + // } + // ] + // } + // } + // } + // + struct resp { - // The spec says the response will never contain any top-level fields - // other than data, errors, and extensions. - // - if (p.name () != "extensions") + optional<gh_check_run> cr; + size_t cr_count = 0; + + resp (json::parser& p) { - throw_json (p, - "unexpected top-level GraphQL response field: '" + - p.name () + '\''); - } + using event = json::event; - p.next_expect_value_skip (); - } - } + gq_parse_response (p, [this] (json::parser& p) + { + p.next_expect (event::begin_object); + p.next_expect_member_object ("node"); + p.next_expect_member_object ("checkRuns"); - // If the `errors` field was present in the response, error(s) occurred - // before or during execution of the operation. - // - // If the `data` field was not present the errors are request errors which - // occur before execution and are typically the client's fault. - // - // If the `data` field was also present in the response the errors are - // field errors which occur during execution and are typically the GraphQL - // endpoint's fault, and some fields in `data` that should not be are - // likely to be null. - // - if (err) - { - if (dat) - { - // @@ TODO: Consider parsing partial data? - // - throw runtime_error ("field error(s) received from GraphQL endpoint; " - "incomplete data received"); - } - else - throw runtime_error ("request error(s) received from GraphQL endpoint"); - } - } + cr_count = p.next_expect_member_number<size_t> ("totalCount"); - // Parse a response to a check_run GraphQL mutation such as `createCheckRun` - // or `updateCheckRun`. - // - // Example response (only the part we need to parse here): - // - // { - // "cr0": { - // "checkRun": { - // "id": "CR_kwDOLc8CoM8AAAAFQ5GqPg", - // "name": "libb2/0.98.1+2/x86_64-linux-gnu/linux_debian_12-gcc_13.1-O3/default/dev/0.17.0-a.1", - // "status": "QUEUED" - // } - // }, - // "cr1": { - // "checkRun": { - // "id": "CR_kwDOLc8CoM8AAAAFQ5GqhQ", - // "name": "libb2/0.98.1+2/x86_64-linux-gnu/linux_debian_12-gcc_13.1/default/dev/0.17.0-a.1", - // "status": "QUEUED" - // } - // } - // } - // - // @@ TODO Handle response errors properly. - // - static vector<check_run> - gq_parse_response_check_runs (json::parser& p) - { - using event = json::event; + p.next_expect_member_array ("edges"); - vector<check_run> r; + for (size_t i (0); i != cr_count; ++i) + { + p.next_expect (event::begin_object); + p.next_expect_name ("node"); + gh_check_run cr (p); + p.next_expect (event::end_object); - parse_graphql_response ( - p, - [&r] (json::parser& p) - { - p.next_expect (event::begin_object); + if (i == 0) + this->cr = move (cr); + } - // Parse the "cr0".."crN" members (field aliases). - // - while (p.next_expect (event::name, event::end_object)) - { - // Parse `"crN": { "checkRun":`. - // - if (p.name () != "cr" + to_string (r.size ())) - throw_json (p, "unexpected field alias: '" + p.name () + '\''); - p.next_expect (event::begin_object); - p.next_expect_name ("checkRun"); + p.next_expect (event::end_array); // edges + p.next_expect (event::end_object); // checkRuns + p.next_expect (event::end_object); // node + p.next_expect (event::end_object); + }); + } - r.emplace_back (p); // Parse the check_run object. + resp () = default; + } rs; - p.next_expect (event::end_object); // Parse end of crN object. - } - }); + uint16_t sc (github_post (rs, + "graphql", + strings {"Authorization: Bearer " + iat}, + gq_serialize_request (rq))); - // Our requests always operate on at least one check run so if there were - // none in the data field something went wrong. - // - if (r.empty ()) - throw_json (p, "data object is empty"); + if (sc == 200) + { + if (rs.cr_count <= 1) + return {rs.cr, true}; + else + { + error << "unexpected number of check runs (" << rs.cr_count + << ") in response"; + } + } + else + error << "failed to get check run by name: error HTTP " + << "response status " << sc; + } + catch (const json::invalid_json_input& e) + { + // Note: e.name is the GitHub API endpoint. + // + error << "malformed JSON in response from " << e.name + << ", line: " << e.line << ", column: " << e.column + << ", byte offset: " << e.position << ", error: " << e; + } + catch (const invalid_argument& e) + { + error << "malformed header(s) in response: " << e; + } + catch (const system_error& e) + { + error << "unable to get check run by name (errno=" << e.code () + << "): " << e.what (); + } + catch (const std::exception& e) + { + error << "unable to get check run by name: " << e.what (); + } - return r; + return {nullopt, false}; } // GraphQL serialization functions. diff --git a/mod/mod-ci-github-gq.hxx b/mod/mod-ci-github-gq.hxx index de7021a..994f8d1 100644 --- a/mod/mod-ci-github-gq.hxx +++ b/mod/mod-ci-github-gq.hxx @@ -7,21 +7,60 @@ #include <libbrep/types.hxx> #include <libbrep/utility.hxx> +#include <libbrep/build.hxx> + +#include <mod/tenant-service.hxx> // build_hints + #include <mod/mod-ci-github-gh.hxx> #include <mod/mod-ci-github-service-data.hxx> - namespace brep { // GraphQL functions (all start with gq_). // - // @@ TODO: - - gq_create_check_run (); - gq_update_check_run (); - - // @@ TODO Pass error, trace in same order everywhere. + // Create a new check run on GitHub for each build. Update `check_runs` with + // the new states and node IDs. Return false and issue diagnostics if the + // request failed. + // + bool + gq_create_check_runs (vector<check_run>& check_runs, + const string& installation_access_token, + const string& repository_id, + const string& head_sha, + const vector<reference_wrapper<const build>>&, + build_state, + const tenant_service_base::build_hints&, + const basic_mark& error); + + // Create a new check run on GitHub for a build. Update `cr` with the new + // state and the node ID. Return false and issue diagnostics if the request + // failed. + // + bool + gq_create_check_run (check_run& cr, + const string& installation_access_token, + const string& repository_id, + const string& head_sha, + const build&, + build_state, + const tenant_service_base::build_hints&, + const basic_mark& error); + + // Update a check run on GitHub. + // + // Send a GraphQL request that updates an existing check run. Update `cr` + // with the new state. Return false and issue diagnostics if the request + // failed. + // + bool + gq_update_check_run (check_run& cr, + const string& installation_access_token, + const string& repository_id, + const string& node_id, + const build&, + build_state, + const basic_mark& error); // Fetch from GitHub the check run with the specified name (hints-shortened // build ID). @@ -33,184 +72,14 @@ namespace brep // Note that the existence of more than one check run with the same name is // considered an error and reported as such. The API docs imply that there // can be more than one check run with the same name in a check suite, but - // the observed behavior is that creating a check run destroys the extant - // one, leaving only the new one with different node ID. + // the observed behavior is that creating a check run destroys the existent + // one, leaving only the new one with a different node ID. // - pair<optional<gh::check_run>, bool> - gq_fetch_check_run (const string& iat, + pair<optional<gh_check_run>, bool> + gq_fetch_check_run (const string& installation_access_token, const string& check_suite_id, const string& cr_name, - const basic_mark& error) noexcept - { - try - { - // Example request: - // - // query { - // node(id: "CS_kwDOLc8CoM8AAAAFQPQYEw") { - // ... on CheckSuite { - // checkRuns(last: 100, filterBy: {checkName: "linux_debian_..."}) { - // totalCount, - // edges { - // node { - // id, name, status - // } - // } - // } - // } - // } - // } - // - // This request does the following: - // - // - Look up the check suite by node ID ("direct node lookup"). This - // returns a Node (GraphQL interface). - // - // - Get to the concrete CheckSuite type by using a GraphQL "inline - // fragment" (`... on CheckSuite`). - // - // - Get the check suite's check runs - // - Filter by the sought name - // - Return only two check runs, just enough to be able to tell - // whether there are more than one check runs with this name (which - // is an error). - // - // - Return the id, name, and status fields from the matching check run - // objects. - // - string rq; - { - ostringstream os; - - os << "query {" << '\n'; - - os << "node(id: " << gq_str (check_suite_id) << ") {" << '\n' - << " ... on CheckSuite {" << '\n' - << " checkRuns(last: 2," << '\n' - << " filterBy: {" << '\n' - << "checkName: " << gq_str (cr_name) << '\n' - << " })" << '\n' - // Specify the selection set (fields to be returned). Note that - // edges and node are mandatory. - // - << " {" << '\n' - << " totalCount," << '\n' - << " edges {" << '\n' - << " node {" << '\n' - << " id, name, status" << '\n' - << " }" << '\n' - << " }" << '\n' - << " }" << '\n' - << " }" << '\n' - << "}" << '\n'; - - os << "}" << '\n'; - - rq = os.str (); - } - - // Example response (the part we need to parse here, at least): - // - // { - // "node": { - // "checkRuns": { - // "totalCount": 1, - // "edges": [ - // { - // "node": { - // "id": "CR_kwDOLc8CoM8AAAAFgeoweg", - // "name": "linux_debian_...", - // "status": "IN_PROGRESS" - // } - // } - // ] - // } - // } - // } - // - struct resp - { - optional<check_run> cr; - size_t cr_count = 0; - - resp (json::parser& p) - { - using event = json::event; - - parse_graphql_response (p, [this] (json::parser& p) - { - p.next_expect (event::begin_object); - p.next_expect_member_object ("node"); - p.next_expect_member_object ("checkRuns"); - - cr_count = p.next_expect_member_number<size_t> ("totalCount"); - - p.next_expect_member_array ("edges"); - - for (size_t i (0); i != cr_count; ++i) - { - p.next_expect (event::begin_object); - p.next_expect_name ("node"); - check_run cr (p); - p.next_expect (event::end_object); - - if (i == 0) - this->cr = move (cr); - } - - p.next_expect (event::end_array); // edges - p.next_expect (event::end_object); // checkRuns - p.next_expect (event::end_object); // node - p.next_expect (event::end_object); - }); - } - - resp () = default; - } rs; - - uint16_t sc (github_post (rs, - "graphql", - strings {"Authorization: Bearer " + iat}, - graphql_request (rq))); - - if (sc == 200) - { - if (rs.cr_count <= 1) - return {rs.cr, true}; - else - { - error << "unexpected number of check runs (" << rs.cr_count - << ") in response"; - } - } - else - error << "failed to get check run by name: error HTTP " - << "response status " << sc; - } - catch (const json::invalid_json_input& e) - { - // Note: e.name is the GitHub API endpoint. - // - error << "malformed JSON in response from " << e.name - << ", line: " << e.line << ", column: " << e.column - << ", byte offset: " << e.position << ", error: " << e; - } - catch (const invalid_argument& e) - { - error << "malformed header(s) in response: " << e; - } - catch (const system_error& e) - { - error << "unable to get check run by name (errno=" << e.code () - << "): " << e.what (); - } - catch (const std::exception& e) - { - error << "unable to get check run by name: " << e.what (); - } - - return {nullopt, false}; - } + const basic_mark& error) noexcept; } #endif // MOD_MOD_CI_GITHUB_GQ_HXX diff --git a/mod/mod-ci-github-post.hxx b/mod/mod-ci-github-post.hxx index f1ed914..d278ae0 100644 --- a/mod/mod-ci-github-post.hxx +++ b/mod/mod-ci-github-post.hxx @@ -7,6 +7,8 @@ #include <libbrep/types.hxx> #include <libbrep/utility.hxx> +#include <libbutl/curl.hxx> + namespace brep { // Send a POST request to the GitHub API endpoint `ep`, parse GitHub's JSON @@ -29,6 +31,8 @@ namespace brep const strings& hdrs, const string& body = "") { + using namespace butl; + // Convert the header values to curl header option/value pairs. // strings hdr_opts; diff --git a/mod/mod-ci-github-service-data.cxx b/mod/mod-ci-github-service-data.cxx new file mode 100644 index 0000000..9f6d86d --- /dev/null +++ b/mod/mod-ci-github-service-data.cxx @@ -0,0 +1,150 @@ +// file : mod/mod-ci-github-service-data.cxx -*- C++ -*- +// license : MIT; see accompanying LICENSE file + +#include <mod/mod-ci-github-service-data.hxx> + +#include <libbutl/json/parser.hxx> +#include <libbutl/json/serializer.hxx> + +namespace brep +{ + using event = json::event; + + service_data:: + service_data (const string& json) + { + json::parser p (json.data (), json.size (), "service_data"); + + p.next_expect (event::begin_object); + + // Throw if the schema version is not supported. + // + version = p.next_expect_member_number<uint64_t> ("version"); + if (version != 1) + { + throw invalid_argument ("unsupported service_data schema version: " + + to_string (version)); + } + + // Installation access token. + // + p.next_expect_member_object ("installation_access"); + installation_access.token = p.next_expect_member_string ("token"); + installation_access.expires_at = + from_iso8601 (p.next_expect_member_string ("expires_at")); + p.next_expect (event::end_object); + + installation_id = + p.next_expect_member_number<uint64_t> ("installation_id"); + repository_id = p.next_expect_member_string ("repository_id"); + head_sha = p.next_expect_member_string ("head_sha"); + + p.next_expect_member_array ("check_runs"); + while (p.next_expect (event::begin_object, event::end_array)) + { + string bid (p.next_expect_member_string ("build_id")); + + optional<string> nid; + { + string* v (p.next_expect_member_string_null ("node_id")); + if (v != nullptr) + nid = *v; + } + + optional<build_state> s; + { + string* v (p.next_expect_member_string_null ("state")); + if (v != nullptr) + s = to_build_state (*v); + } + + check_runs.emplace_back (move (bid), move (nid), s); + + p.next_expect (event::end_object); + } + + p.next_expect (event::end_object); + } + + service_data:: + service_data (string iat_tok, + timestamp iat_ea, + uint64_t iid, + string rid, + string hs) + : installation_access (move (iat_tok), iat_ea), + installation_id (iid), + repository_id (move (rid)), + head_sha (move (hs)) + { + } + + string service_data:: + json () const + { + string b; + json::buffer_serializer s (b); + + s.begin_object (); + + s.member ("version", 1); + + // Installation access token. + // + s.member_begin_object ("installation_access"); + s.member ("token", installation_access.token); + s.member ("expires_at", to_iso8601 (installation_access.expires_at)); + s.end_object (); + + s.member ("installation_id", installation_id); + s.member ("repository_id", repository_id); + s.member ("head_sha", head_sha); + + s.member_begin_array ("check_runs"); + for (const check_run& cr: check_runs) + { + s.begin_object (); + s.member ("build_id", cr.build_id); + + s.member_name ("node_id"); + if (cr.node_id) + s.value (*cr.node_id); + else + s.value (nullptr); + + s.member_name ("state"); + if (cr.state) + s.value (to_string (*cr.state)); + else + s.value (nullptr); + + s.end_object (); + } + s.end_array (); + + s.end_object (); + + return b; + } + + check_run* service_data:: + find_check_run (const string& bid) + { + for (check_run& cr: check_runs) + { + if (cr.build_id == bid) + return &cr; + } + return nullptr; + } + + ostream& + operator<< (ostream& os, const check_run& cr) + { + os << "node_id: " << cr.node_id.value_or ("null") + << ", build_id: " << cr.build_id + << ", state: " << (cr.state ? to_string (*cr.state) : "null"); + + return os; + } +} diff --git a/mod/mod-ci-github-service-data.hxx b/mod/mod-ci-github-service-data.hxx index 4d0af96..7ea01ff 100644 --- a/mod/mod-ci-github-service-data.hxx +++ b/mod/mod-ci-github-service-data.hxx @@ -1,4 +1,4 @@ -// file : mod/mod-ci-github-serice-data.hxx -*- C++ -*- +// file : mod/mod-ci-github-service-data.hxx -*- C++ -*- // license : MIT; see accompanying LICENSE file #ifndef MOD_MOD_CI_GITHUB_SERVICE_DATA_HXX @@ -26,16 +26,27 @@ namespace brep { string build_id; // Full build id. optional<string> node_id; // GitHub id. - build_state state; - bool state_synced; + + // @@ TODO + // + // build_state state; + // bool state_synced; + + // string + // state_string () const + // { + // string r (to_string (*state)); + // if (!state_synced) + // r += "(unsynchronized)"; + // return r; + // } + + optional<build_state> state; string state_string () const { - string r (to_string (*state)); - if (!state_synced) - r += "(unsynchronized)"; - return r; + return state ? to_string (*state) : "null"; } }; @@ -47,7 +58,7 @@ namespace brep // Check suite-global data. // - installation_access_token installation_access; + gh_installation_access_token installation_access; uint64_t installation_id; // @@ TODO Rename to repository_node_id. @@ -86,7 +97,7 @@ namespace brep }; ostream& - operator<< (ostream&, const service_data::check_run&); + operator<< (ostream&, const check_run&); } #endif // MOD_MOD_CI_GITHUB_SERVICE_DATA_HXX diff --git a/mod/mod-ci-github.cxx b/mod/mod-ci-github.cxx index 179eeba..b9b24f4 100644 --- a/mod/mod-ci-github.cxx +++ b/mod/mod-ci-github.cxx @@ -3,14 +3,16 @@ #include <mod/mod-ci-github.hxx> -#include <libbutl/curl.hxx> #include <libbutl/json/parser.hxx> -#include <libbutl/json/serializer.hxx> #include <mod/jwt.hxx> #include <mod/hmac.hxx> #include <mod/module-options.hxx> +#include <mod/mod-ci-github-gq.hxx> +#include <mod/mod-ci-github-post.hxx> +#include <mod/mod-ci-github-service-data.hxx> + #include <stdexcept> // @@ TODO @@ -71,8 +73,6 @@ using namespace brep::cli; namespace brep { - using namespace gh; - ci_github:: ci_github (tenant_service_map& tsm) : tenant_service_map_ (tsm) @@ -263,12 +263,12 @@ namespace brep // if (event == "check_suite") { - check_suite_event cs; + gh_check_suite_event cs; try { json::parser p (body.data (), body.size (), "check_suite event"); - cs = check_suite_event (p); + cs = gh_check_suite_event (p); } catch (const json::invalid_json_input& e) { @@ -330,7 +330,7 @@ namespace brep } bool ci_github:: - handle_check_suite_request (check_suite_event cs) + handle_check_suite_request (gh_check_suite_event cs) { HANDLER_DIAG; @@ -340,7 +340,7 @@ namespace brep if (!jwt) throw server_error (); - optional<installation_access_token> iat ( + optional<gh_installation_access_token> iat ( obtain_installation_access_token (cs.installation.id, move (*jwt), error)); @@ -406,7 +406,7 @@ namespace brep // thus would cause a spurious backwards state transition. // vector<reference_wrapper<const build>> bs; - vector<service_data::check_run> crs; // Parallel to bs. + vector<check_run> crs; // Parallel to bs. // Exclude builds for which this is an out of order notification. // @@ -423,9 +423,9 @@ namespace brep // // Note: never go back on the built state. // - string bid (check_run_name (b)); // Full Build ID. + string bid (gh_check_run_name (b)); // Full Build ID. - const service_data::check_run* scr (sd.find_check_run (bid)); + const check_run* scr (sd.find_check_run (bid)); if (scr == nullptr) { @@ -459,8 +459,8 @@ namespace brep // Get a new installation access token if the current one has expired. // - const installation_access_token* iat (nullptr); - optional<installation_access_token> new_iat; + const gh_installation_access_token* iat (nullptr); + optional<gh_installation_access_token> new_iat; if (system_clock::now () > sd.installation_access.expires_at) { @@ -483,20 +483,15 @@ namespace brep // // Queue a check_run for each build. // - string rq (graphql_request (create_check_runs (sd.repository_id, - sd.head_sha, - bs, - build_state::queued, - &hs))); - - if (mutate_check_runs (crs, - bs, - iat->token, - move (rq), - build_state::queued, - error)) - { - for (service_data::check_run& cr: crs) + if (gq_create_check_runs (crs, + iat->token, + sd.repository_id, sd.head_sha, + bs, + build_state::queued, + hs, + error)) + { + for (check_run& cr: crs) l3 ([&] { trace << "created check_run { " << cr << " }"; }); } } @@ -529,14 +524,14 @@ namespace brep // for (size_t i (0); i != bs.size (); ++i) { - const service_data::check_run& cr (crs[i]); + const check_run& cr (crs[i]); // Note that this service data may not be the same as what we observed // in the build_queued() function above. For example, some check runs // that we have queued may have already transitioned to building. So // we skip any check runs that are already present. // - if (service_data::check_run* scr = sd.find_check_run (cr.build_id)) + if (check_run* scr = sd.find_check_run (cr.build_id)) { warn << cr << " state " << scr->state_string () << " was stored before notified state " << cr.state_string () @@ -579,43 +574,12 @@ namespace brep return nullptr; } - service_data::check_run cr; // Updated check run. - - // Create a new check run on GitHub. - // - auto create = [&cr, &b, &hs, &sd, &error] (const string& iat) - { - string rq (graphql_request (create_check_runs (sd.repository_id, - sd.head_sha, - {b}, - build_state::building, - &hs))); - - return mutate_check_run (cr, - {b}, - iat, - move (rq), - build_state::building, - error); - }; - - // Update a check run that already exists on GitHub. - // - auto update = - [&cr, &b, &sd, &error] (const string& iat, - const string& nid, - build_state st = build_state::building) - { - string rq (graphql_request ( - update_check_run (sd.repository_id, nid, build_state::building))); - - return mutate_check_run (cr, {b}, iat, move (rq), st, error); - }; + check_run cr; // Updated check run. // Get a new installation access token if the current one has expired. // - const installation_access_token* iat (nullptr); - optional<installation_access_token> new_iat; + const gh_installation_access_token* iat (nullptr); + optional<gh_installation_access_token> new_iat; if (system_clock::now () > sd.installation_access.expires_at) { @@ -633,11 +597,11 @@ namespace brep if (iat != nullptr) { - string bid (check_run_name (b)); // Full Build ID. + string bid (gh_check_run_name (b)); // Full Build ID. // Stored check run. // - const service_data::check_run* scr (sd.find_check_run (bid)); + const check_run* scr (sd.find_check_run (bid)); if (scr != nullptr && scr->node_id) { @@ -683,7 +647,13 @@ namespace brep build_state st (scr->state ? build_state::building : build_state::built); - if (update (iat->token, *cr.node_id, st)) + if (gq_update_check_run (cr, + iat->token, + sd.repository_id, + *cr.node_id, + b, + st, + error)) { // @@ TODO If !scr->state and GH had built then we probably don't // want to run the lambda either but currently it will run @@ -743,11 +713,11 @@ namespace brep // Fetch the check run by name from GitHub. // - pair<optional<check_run>, bool> pr ( - fetch_check_run (iat->token, - ts.id, - check_run_name (b, &hs), - error)); + pair<optional<gh_check_run>, bool> pr ( + gq_fetch_check_run (iat->token, + ts.id, + gh_check_run_name (b, &hs), + error)); if (pr.second) // No errors. { @@ -762,12 +732,20 @@ namespace brep // @@ TODO Create with whatever the failed state was if we decide // to store it. // - if (create (iat->token)) + if (gq_create_check_run (cr, + iat->token, + sd.repository_id, sd.head_sha, + b, + build_state::queued, + hs, + error)) + { l3 ([&]{trace << "created check_run { " << cr << " }";}); + } } else // Check run exists on GitHub. { - if (pr.first->status == to_string_gh (build_state::queued)) + if (pr.first->status == gh_to_status (build_state::queued)) { if (scr != nullptr) { @@ -775,8 +753,16 @@ namespace brep cr.state = nullopt; } - if (update (iat->token, pr.first->node_id)) + if (gq_update_check_run (cr, + iat->token, + sd.repository_id, + pr.first->node_id, + b, + build_state::building, + error)) + { l3 ([&]{trace << "updated check_run { " << cr << " }";}); + } } else { @@ -823,7 +809,7 @@ namespace brep if (iat) sd.installation_access = *iat; - if (service_data::check_run* scr = sd.find_check_run (cr.build_id)) + if (check_run* scr = sd.find_check_run (cr.build_id)) { // Update existing check run. // @@ -929,12 +915,12 @@ namespace brep // repos covered by the installation if installed on an organisation for // example. // - optional<installation_access_token> ci_github:: + optional<gh_installation_access_token> ci_github:: obtain_installation_access_token (uint64_t iid, string jwt, const basic_mark& error) const { - installation_access_token iat; + gh_installation_access_token iat; try { // API endpoint. @@ -988,425 +974,4 @@ namespace brep return iat; } - - static string - to_iso8601 (timestamp t) - { - return butl::to_string (t, - "%Y-%m-%dT%TZ", - false /* special */, - false /* local */); - } - - static timestamp - from_iso8601 (const string& s) - { - return butl::from_string (s.c_str (), "%Y-%m-%dT%TZ", false /* local */); - } - - using event = json::event; - - service_data:: - service_data (const string& json) - { - json::parser p (json.data (), json.size (), "service_data"); - - p.next_expect (event::begin_object); - - // Throw if the schema version is not supported. - // - version = p.next_expect_member_number<uint64_t> ("version"); - if (version != 1) - { - throw invalid_argument ("unsupported service_data schema version: " + - to_string (version)); - } - - // Installation access token. - // - p.next_expect_member_object ("installation_access"); - installation_access.token = p.next_expect_member_string ("token"); - installation_access.expires_at = - from_iso8601 (p.next_expect_member_string ("expires_at")); - p.next_expect (event::end_object); - - installation_id = - p.next_expect_member_number<uint64_t> ("installation_id"); - repository_id = p.next_expect_member_string ("repository_id"); - head_sha = p.next_expect_member_string ("head_sha"); - - p.next_expect_member_array ("check_runs"); - while (p.next_expect (event::begin_object, event::end_array)) - { - string bid (p.next_expect_member_string ("build_id")); - - optional<string> nid; - { - string* v (p.next_expect_member_string_null ("node_id")); - if (v != nullptr) - nid = *v; - } - - optional<build_state> s; - { - string* v (p.next_expect_member_string_null ("state")); - if (v != nullptr) - s = to_build_state (*v); - } - - check_runs.emplace_back (move (bid), move (nid), s); - - p.next_expect (event::end_object); - } - - p.next_expect (event::end_object); - } - - service_data:: - service_data (string iat_tok, - timestamp iat_ea, - uint64_t iid, - string rid, - string hs) - : installation_access (move (iat_tok), iat_ea), - installation_id (iid), - repository_id (move (rid)), - head_sha (move (hs)) - { - } - - string service_data:: - json () const - { - string b; - json::buffer_serializer s (b); - - s.begin_object (); - - s.member ("version", 1); - - // Installation access token. - // - s.member_begin_object ("installation_access"); - s.member ("token", installation_access.token); - s.member ("expires_at", to_iso8601 (installation_access.expires_at)); - s.end_object (); - - s.member ("installation_id", installation_id); - s.member ("repository_id", repository_id); - s.member ("head_sha", head_sha); - - s.member_begin_array ("check_runs"); - for (const check_run& cr: check_runs) - { - s.begin_object (); - s.member ("build_id", cr.build_id); - - s.member_name ("node_id"); - if (cr.node_id) - s.value (*cr.node_id); - else - s.value (nullptr); - - s.member_name ("state"); - if (cr.state) - s.value (to_string (*cr.state)); - else - s.value (nullptr); - - s.end_object (); - } - s.end_array (); - - s.end_object (); - - return b; - } - - service_data::check_run* service_data:: - find_check_run (const string& bid) - { - for (check_run& cr: check_runs) - { - if (cr.build_id == bid) - return &cr; - } - return nullptr; - } - - ostream& - operator<< (ostream& os, const service_data::check_run& cr) - { - os << "node_id: " << cr.node_id.value_or ("null") - << ", build_id: " << cr.build_id - << ", state: " << (cr.state ? to_string (*cr.state) : "null"); - - return os; - } - - // The rest is GitHub request/response type parsing and printing. - // - - // Throw invalid_json_input when a required member `m` is missing from a - // JSON object `o`. - // - [[noreturn]] static void - missing_member (const json::parser& p, const char* o, const char* m) - { - throw json::invalid_json_input ( - p.input_name, - p.line (), p.column (), p.position (), - o + string (" object is missing member '") + m + '\''); - } - - // check_suite - // - gh::check_suite:: - check_suite (json::parser& p) - { - p.next_expect (event::begin_object); - - bool ni (false), hb (false), hs (false), bf (false), at (false); - - // Skip unknown/uninteresting members. - // - while (p.next_expect (event::name, event::end_object)) - { - auto c = [&p] (bool& v, const char* s) - { - return p.name () == s ? (v = true) : false; - }; - - if (c (ni, "node_id")) node_id = p.next_expect_string (); - else if (c (hb, "head_branch")) head_branch = p.next_expect_string (); - else if (c (hs, "head_sha")) head_sha = p.next_expect_string (); - else if (c (bf, "before")) before = p.next_expect_string (); - else if (c (at, "after")) after = p.next_expect_string (); - else p.next_expect_value_skip (); - } - - if (!ni) missing_member (p, "check_suite", "node_id"); - if (!hb) missing_member (p, "check_suite", "head_branch"); - if (!hs) missing_member (p, "check_suite", "head_sha"); - if (!bf) missing_member (p, "check_suite", "before"); - if (!at) missing_member (p, "check_suite", "after"); - } - - ostream& gh:: - operator<< (ostream& os, const check_suite& cs) - { - os << "node_id: " << cs.node_id - << ", head_branch: " << cs.head_branch - << ", head_sha: " << cs.head_sha - << ", before: " << cs.before - << ", after: " << cs.after; - - return os; - } - - // check_run - // - gh::check_run:: - check_run (json::parser& p) - { - p.next_expect (event::begin_object); - - bool ni (false), nm (false), st (false); - - while (p.next_expect (event::name, event::end_object)) - { - auto c = [&p] (bool& v, const char* s) - { - return p.name () == s ? (v = true) : false; - }; - - if (c (ni, "id")) node_id = p.next_expect_string (); - else if (c (nm, "name")) name = p.next_expect_string (); - else if (c (st, "status")) status = p.next_expect_string (); - } - - if (!ni) missing_member (p, "check_run", "id"); - if (!nm) missing_member (p, "check_run", "name"); - if (!st) missing_member (p, "check_run", "status"); - } - - ostream& gh:: - operator<< (ostream& os, const check_run& cr) - { - os << "node_id: " << cr.node_id - << ", name: " << cr.name - << ", status: " << cr.status; - - return os; - } - - // repository - // - gh::repository:: - repository (json::parser& p) - { - p.next_expect (event::begin_object); - - bool ni (false), nm (false), fn (false), db (false), cu (false); - - // Skip unknown/uninteresting members. - // - while (p.next_expect (event::name, event::end_object)) - { - auto c = [&p] (bool& v, const char* s) - { - return p.name () == s ? (v = true) : false; - }; - - if (c (ni, "node_id")) node_id = p.next_expect_string (); - else if (c (nm, "name")) name = p.next_expect_string (); - else if (c (fn, "full_name")) full_name = p.next_expect_string (); - else if (c (db, "default_branch")) default_branch = p.next_expect_string (); - else if (c (cu, "clone_url")) clone_url = p.next_expect_string (); - else p.next_expect_value_skip (); - } - - if (!ni) missing_member (p, "repository", "node_id"); - if (!nm) missing_member (p, "repository", "name"); - if (!fn) missing_member (p, "repository", "full_name"); - if (!db) missing_member (p, "repository", "default_branch"); - if (!cu) missing_member (p, "repository", "clone_url"); - } - - ostream& gh:: - operator<< (ostream& os, const repository& rep) - { - os << "node_id: " << rep.node_id - << ", name: " << rep.name - << ", full_name: " << rep.full_name - << ", default_branch: " << rep.default_branch - << ", clone_url: " << rep.clone_url; - - return os; - } - - // installation - // - gh::installation:: - installation (json::parser& p) - { - p.next_expect (event::begin_object); - - bool i (false); - - // Skip unknown/uninteresting members. - // - while (p.next_expect (event::name, event::end_object)) - { - auto c = [&p] (bool& v, const char* s) - { - return p.name () == s ? (v = true) : false; - }; - - if (c (i, "id")) id = p.next_expect_number<uint64_t> (); - else p.next_expect_value_skip (); - } - - if (!i) missing_member (p, "installation", "id"); - } - - ostream& gh:: - operator<< (ostream& os, const installation& i) - { - os << "id: " << i.id; - - return os; - } - - // check_suite_event - // - gh::check_suite_event:: - check_suite_event (json::parser& p) - { - p.next_expect (event::begin_object); - - bool ac (false), cs (false), rp (false), in (false); - - // Skip unknown/uninteresting members. - // - while (p.next_expect (event::name, event::end_object)) - { - auto c = [&p] (bool& v, const char* s) - { - return p.name () == s ? (v = true) : false; - }; - - if (c (ac, "action")) action = p.next_expect_string (); - else if (c (cs, "check_suite")) check_suite = gh::check_suite (p); - else if (c (rp, "repository")) repository = gh::repository (p); - else if (c (in, "installation")) installation = gh::installation (p); - else p.next_expect_value_skip (); - } - - if (!ac) missing_member (p, "check_suite_event", "action"); - if (!cs) missing_member (p, "check_suite_event", "check_suite"); - if (!rp) missing_member (p, "check_suite_event", "repository"); - if (!in) missing_member (p, "check_suite_event", "installation"); - } - - ostream& gh:: - operator<< (ostream& os, const check_suite_event& cs) - { - os << "action: " << cs.action; - os << ", check_suite { " << cs.check_suite << " }"; - os << ", repository { " << cs.repository << " }"; - os << ", installation { " << cs.installation << " }"; - - return os; - } - - // installation_access_token - // - // Example JSON: - // - // { - // "token": "ghs_Py7TPcsmsITeVCAWeVtD8RQs8eSos71O5Nzp", - // "expires_at": "2024-02-15T16:16:38Z", - // ... - // } - // - gh::installation_access_token:: - installation_access_token (json::parser& p) - { - p.next_expect (event::begin_object); - - bool tk (false), ea (false); - - // Skip unknown/uninteresting members. - // - while (p.next_expect (event::name, event::end_object)) - { - auto c = [&p] (bool& v, const char* s) - { - return p.name () == s ? (v = true) : false; - }; - - if (c (tk, "token")) token = p.next_expect_string (); - else if (c (ea, "expires_at")) expires_at = from_iso8601 (p.next_expect_string ()); - else p.next_expect_value_skip (); - } - - if (!tk) missing_member (p, "installation_access_token", "token"); - if (!ea) missing_member (p, "installation_access_token", "expires_at"); - } - - gh::installation_access_token:: - installation_access_token (string tk, timestamp ea) - : token (move (tk)), expires_at (ea) - { - } - - ostream& gh:: - operator<< (ostream& os, const installation_access_token& t) - { - os << "token: " << t.token << ", expires_at: "; - butl::operator<< (os, t.expires_at); - - return os; - } } diff --git a/mod/mod-ci-github.hxx b/mod/mod-ci-github.hxx index a6cd180..4b23d85 100644 --- a/mod/mod-ci-github.hxx +++ b/mod/mod-ci-github.hxx @@ -13,6 +13,8 @@ #include <mod/ci-common.hxx> #include <mod/tenant-service.hxx> +#include <mod/mod-ci-github-gh.hxx> + namespace brep { class ci_github: public handler, @@ -61,14 +63,14 @@ namespace brep // Handle the check_suite event `requested` and `rerequested` actions. // bool - handle_check_suite_request (gh::check_suite_event); + handle_check_suite_request (gh_check_suite_event); optional<string> generate_jwt (const basic_mark& trace, const basic_mark& error) const; // Authenticate to GitHub as an app installation. // - optional<gh::installation_access_token> + optional<gh_installation_access_token> obtain_installation_access_token (uint64_t install_id, string jwt, const basic_mark& error) const; |