From d27f174bf1484c8417d97fefc20d8a16637a8a61 Mon Sep 17 00:00:00 2001 From: Boris Kolpackov Date: Wed, 17 Apr 2024 10:47:33 +0200 Subject: Review (restructure) --- mod/mod-ci-github-gh.hxx | 197 ++++++++ mod/mod-ci-github-gq.hxx | 216 +++++++++ mod/mod-ci-github-post.hxx | 157 ++++++ mod/mod-ci-github-qg.cxx | 509 ++++++++++++++++++++ mod/mod-ci-github-service-data.hxx | 92 ++++ mod/mod-ci-github.cxx | 952 ------------------------------------- mod/mod-ci-github.hxx | 129 ----- 7 files changed, 1171 insertions(+), 1081 deletions(-) create mode 100644 mod/mod-ci-github-gh.hxx create mode 100644 mod/mod-ci-github-gq.hxx create mode 100644 mod/mod-ci-github-post.hxx create mode 100644 mod/mod-ci-github-qg.cxx create mode 100644 mod/mod-ci-github-service-data.hxx diff --git a/mod/mod-ci-github-gh.hxx b/mod/mod-ci-github-gh.hxx new file mode 100644 index 0000000..23ad247 --- /dev/null +++ b/mod/mod-ci-github-gh.hxx @@ -0,0 +1,197 @@ +// file : mod/mod-ci-github-gh.hxx -*- C++ -*- +// license : MIT; see accompanying LICENSE file + +#ifndef MOD_MOD_CI_GITHUB_GH_HXX +#define MOD_MOD_CI_GITHUB_GH_HXX + +#include +#include + +namespace butl +{ + namespace json + { + class parser; + } +} + +namespace brep +{ + // GitHub request/response types (all start with gh_). + // + // Note that the GitHub REST and GraphQL APIs use different ID types and + // values. In the REST API they are usually integers (but sometimes + // strings!) whereas in GraphQL they are always strings (note: + // base64-encoded and opaque, not just the REST ID value as a string). + // + // In both APIs the ID field is called `id`, but REST responses and webhook + // events also contain the corresponding GraphQL object's ID in the + // `node_id` field. + // + // In the structures below we always use the RESP API/webhook names for ID + // fields. I.e., `id` always refers to the REST/webhook ID, and `node_id` + // always refers to the GraphQL ID. + // + namespace json = butl::json; + + // The "check_suite" object within a check_suite webhook event request. + // + struct gh_check_suite + { + string node_id; + string head_branch; + string head_sha; + string before; + string after; + + explicit + check_suite (json::parser&); + + check_suite () = default; + }; + + struct check_run + { + string node_id; + string name; + string status; + + explicit + check_run (json::parser&); + + 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 (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 + + '\''); + } + + // 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; + } + + struct repository + { + string node_id; + string name; + string full_name; + string default_branch; + string clone_url; + + explicit + repository (json::parser&); + + repository () = default; + }; + + struct installation + { + uint64_t id; // Note: used for installation access token (REST API). + + explicit + installation (json::parser&); + + installation () = default; + }; + + // The check_suite webhook event request. + // + struct check_suite_event + { + string action; + gh::check_suite check_suite; + gh::repository repository; + gh::installation installation; + + explicit + check_suite_event (json::parser&); + + check_suite_event () = default; + }; + + struct installation_access_token + { + string token; + timestamp expires_at; + + explicit + installation_access_token (json::parser&); + + installation_access_token (string token, timestamp expires_at); + + installation_access_token () = default; + }; + + ostream& + operator<< (ostream&, const check_suite&); + + ostream& + operator<< (ostream&, const check_run&); + + ostream& + operator<< (ostream&, const repository&); + + ostream& + operator<< (ostream&, const installation&); + + ostream& + operator<< (ostream&, const check_suite_event&); + + ostream& + operator<< (ostream&, const installation_access_token&); +} + +#endif // MOD_MOD_CI_GITHUB_GH_HXX diff --git a/mod/mod-ci-github-gq.hxx b/mod/mod-ci-github-gq.hxx new file mode 100644 index 0000000..de7021a --- /dev/null +++ b/mod/mod-ci-github-gq.hxx @@ -0,0 +1,216 @@ +// file : mod/mod-ci-github-gq.hxx -*- C++ -*- +// license : MIT; see accompanying LICENSE file + +#ifndef MOD_MOD_CI_GITHUB_GQ_HXX +#define MOD_MOD_CI_GITHUB_GQ_HXX + +#include +#include + +#include +#include + + +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. + + // Fetch from GitHub the check run with the specified name (hints-shortened + // build ID). + // + // Return the check run or nullopt if no such check run exists. + // + // In case of error diagnostics will be issued and false returned in second. + // + // 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. + // + pair, 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; + { + 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 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 ("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}; + } +} + +#endif // MOD_MOD_CI_GITHUB_GQ_HXX diff --git a/mod/mod-ci-github-post.hxx b/mod/mod-ci-github-post.hxx new file mode 100644 index 0000000..f1ed914 --- /dev/null +++ b/mod/mod-ci-github-post.hxx @@ -0,0 +1,157 @@ +// file : mod/mod-ci-github-post.hxx -*- C++ -*- +// license : MIT; see accompanying LICENSE file + +#ifndef MOD_MOD_CI_GITHUB_POST_HXX +#define MOD_MOD_CI_GITHUB_POST_HXX + +#include +#include + +namespace brep +{ + // Send a POST request to the GitHub API endpoint `ep`, parse GitHub's JSON + // response into `rs` (only for 200 codes), and return the HTTP status code. + // + // The endpoint `ep` should not have a leading slash. + // + // Pass additional HTTP headers in `hdrs`. For example: + // + // "HeaderName: header value" + // + // Throw invalid_argument if unable to parse the response headers, + // invalid_json_input (derived from invalid_argument) if unable to parse the + // response body, and system_error in other cases. + // + template + uint16_t + github_post (T& rs, + const string& ep, + const strings& hdrs, + const string& body = "") + { + // Convert the header values to curl header option/value pairs. + // + strings hdr_opts; + + for (const string& h: hdrs) + { + hdr_opts.push_back ("--header"); + hdr_opts.push_back (h); + } + + // Run curl. + // + try + { + // Pass --include to print the HTTP status line (followed by the response + // headers) so that we can get the response status code. + // + // Suppress the --fail option which causes curl to exit with status 22 + // in case of an error HTTP response status code (>= 400) otherwise we + // can't get the status code. + // + // Note that butl::curl also adds --location to make curl follow redirects + // (which is recommended by GitHub). + // + // The API version `2022-11-28` is the only one currently supported. If + // the X-GitHub-Api-Version header is not passed this version will be + // chosen by default. + // + fdpipe errp (fdopen_pipe ()); // stderr pipe. + + curl c (path ("-"), // Read input from curl::out. + path ("-"), // Write response to curl::in. + process::pipe (errp.in.get (), move (errp.out)), + curl::post, + curl::flags::no_fail, + "https://api.github.com/" + ep, + "--no-fail", // Don't fail if response status code >= 400. + "--include", // Output response headers for status code. + "--header", "Accept: application/vnd.github+json", + "--header", "X-GitHub-Api-Version: 2022-11-28", + move (hdr_opts)); + + ifdstream err (move (errp.in)); + + // Parse the HTTP response. + // + uint16_t sc; // Status code. + try + { + // Note: re-open in/out so that they get automatically closed on + // exception. + // + ifdstream in (c.in.release (), fdstream_mode::skip); + ofdstream out (c.out.release ()); + + // Write request body to out. + // + if (!body.empty ()) + out << body; + out.close (); + + sc = curl::read_http_status (in).code; // May throw invalid_argument. + + // Parse the response body if the status code is in the 200 range. + // + if (sc >= 200 && sc < 300) + { + // Use endpoint name as input name (useful to have it propagated + // in exceptions). + // + json::parser p (in, ep /* name */); + rs = T (p); + } + + in.close (); + } + catch (const io_error& e) + { + // If the process exits with non-zero status, assume the IO error is due + // to that and fall through. + // + if (c.wait ()) + { + throw_generic_error ( + e.code ().value (), + (string ("unable to read curl stdout: ") + e.what ()).c_str ()); + } + } + catch (const json::invalid_json_input&) + { + // If the process exits with non-zero status, assume the JSON error is + // due to that and fall through. + // + if (c.wait ()) + throw; + } + + if (!c.wait ()) + { + string et (err.read_text ()); + throw_generic_error (EINVAL, + ("non-zero curl exit status: " + et).c_str ()); + } + + err.close (); + + return sc; + } + catch (const process_error& e) + { + throw_generic_error ( + e.code ().value (), + (string ("unable to execute curl:") + e.what ()).c_str ()); + } + catch (const io_error& e) + { + // Unable to read diagnostics from stderr. + // + throw_generic_error ( + e.code ().value (), + (string ("unable to read curl stderr : ") + e.what ()).c_str ()); + } + } +} + +#endif // MOD_MOD_CI_GITHUB_POST_HXX diff --git a/mod/mod-ci-github-qg.cxx b/mod/mod-ci-github-qg.cxx new file mode 100644 index 0000000..b0e40a6 --- /dev/null +++ b/mod/mod-ci-github-qg.cxx @@ -0,0 +1,509 @@ +// file : mod/mod-ci-github-gq.cxx -*- C++ -*- +// license : MIT; see accompanying LICENSE file + +#include + +namespace brep +{ + // GraphQL serialization functions (see definitions and documentation at the + // bottom). + // + static const string& gq_name (const string&); + static string gq_str (const string&); + static string gq_bool (bool); + static const string& gq_enum (const string&); + + // 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. + // + static bool + gq_mutate_check_runs (vector& crs, + const vector>& bs, + const string& iat, + string rq, + build_state st, + const basic_mark& error) noexcept + { + vector rcrs; + + try + { + // Response type which parses a GraphQL response containing multiple + // check_run objects. + // + struct resp + { + vector check_runs; // Received check runs. + + resp (json::parser& p) : check_runs (parse_check_runs_response (p)) {} + + resp () = default; + } rs; + + uint16_t sc (github_post (rs, + "graphql", // API Endpoint. + strings {"Authorization: Bearer " + iat}, + move (rq))); + + if (sc == 200) + { + rcrs = move (rs.check_runs); + + if (rcrs.size () == bs.size ()) + { + for (size_t i (0); i != rcrs.size (); ++i) + { + // Validate the check run in the response against the build. + // + const check_run& rcr (rcrs[i]); // Received check run. + const build& b (bs[i]); + + build_state rst (from_string_gh (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) << '\''; + + return false; // Fail because something is clearly very wrong. + } + else + { + service_data::check_run& cr (crs[i]); + + if (!cr.node_id) + cr.node_id = move (rcr.node_id); + + cr.state = from_string_gh (rcr.status); + } + } + + return true; + } + else + error << "unexpected number of check_run objects in response"; + } + else + error << "failed to update check run: 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 mutate check runs (errno=" << e.code () << "): " + << e.what (); + } + catch (const runtime_error& e) // From parse_check_runs_response(). + { + // GitHub response contained error(s) (could be ours or theirs at this + // point). + // + error << "unable to mutate check runs: " << e; + } + + return false; + } + + static bool + gq_mutate_check_run (service_data::check_run& cr, + const vector>& bs, + const string& iat, + string rq, + build_state st, + const basic_mark& error) noexcept + { + vector crs {move (cr)}; + + bool r (mutate_check_runs (crs, bs, iat, move (rq), st, error)); + + cr = move (crs[0]); + + return r; + } + + // 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>& bs, + build_state st, + const tenant_service_base::build_hints* bh = nullptr) + { + ostringstream os; + + os << "mutation {" << '\n'; + + // Serialize a `createCheckRun` for each build. + // + for (size_t i (0); i != bs.size (); ++i) + { + const build& b (bs[i]); + + string al ("cr" + to_string (i)); // Field alias. + + // Check run name. + // + string nm (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' + << "})" << '\n' + // Specify the selection set (fields to be returned). + // + << "{" << '\n' + << " checkRun {" << '\n' + << " id," << '\n' + << " name," << '\n' + << " status" << '\n' + << " }" << '\n' + << "}" << '\n'; + } + + os << "}" << '\n'; + + return os.str (); + } + + // Serialize an `updateCheckRun` mutation for one build to GraphQL. + // + // @@ 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) + { + ostringstream os; + + os << "mutation {" << '\n' + << "cr0:updateCheckRun(input: {" << '\n' + << " checkRunId: " << gq_str (ci) << ',' << '\n' + << " repositoryId: " << gq_str (ri) << ',' << '\n' + << " status: " << gq_enum (to_string_gh (st)) << '\n' + << "})" << '\n' + // Specify the selection set (fields to be returned). + // + << "{" << '\n' + << " checkRun {" << '\n' + << " id," << '\n' + << " name," << '\n' + << " status" << '\n' + << " }" << '\n' + << "}" << '\n' + << "}" << '\n'; + + 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) + { + string b; + json::buffer_serializer s (b); + + s.begin_object (); + s.member ("query", o); + s.end_object (); + + return b; + } + + [[noreturn]] 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 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 + gq_parse_response_check_runs (json::parser& p) + { + using event = json::event; + + vector r; + + parse_graphql_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; + } + + // GraphQL serialization functions. + // + // The GraphQL spec: + // https://spec.graphql.org/ + // + // The GitHub GraphQL API reference: + // https://docs.github.com/en/graphql/reference/ + // + + // Check that a string is a valid GraphQL name. + // + // GraphQL names can contain only alphanumeric characters and underscores + // and cannot begin with a digit (so basically a C identifier). + // + // Return the name or throw invalid_argument if it is invalid. + // + // @@ TODO: dangerous API. + // + static const string& + gq_name (const string& v) + { + if (v.empty () || digit (v[0])) + throw invalid_argument ("invalid GraphQL name: '" + v + '\''); + + for (char c: v) + { + if (!alnum (c) && c != '_') + { + throw invalid_argument ("invalid character in GraphQL name: '" + c + + '\''); + } + } + + return v; + } + + // Serialize a string to GraphQL. + // + // Return the serialized string or throw invalid_argument if the string is + // invalid. + // + static string + gq_str (const string& v) + { + // GraphQL strings are the same as JSON strings so we use the JSON + // serializer. + // + string b; + json::buffer_serializer s (b); + + try + { + s.value (v); + } + catch (const json::invalid_json_output&) + { + throw invalid_argument ("invalid GraphQL string: '" + v + '\''); + } + + return b; + } + + // Serialize an int to GraphQL. + // +#if 0 + static string + gq_int (uint64_t v) + { + string b; + json::buffer_serializer s (b); + s.value (v); + return b; + } +#endif + + // Serialize a boolean to GraphQL. + // + static inline string + gq_bool (bool v) + { + return v ? "true" : "false"; + } + + // Check that a string is a valid GraphQL enum value. + // + // GraphQL enum values can be any GraphQL name except for `true`, `false`, + // or `null`. + // + // Return the enum value or throw invalid_argument if it is invalid. + // + // @@ TODO: dangerous API. + // + static const string& + gq_enum (const string& v) + { + if (v == "true" || v == "false" || v == "null") + throw invalid_argument ("invalid GraphQL enum value: '" + v + '\''); + + return gq_name (v); + } +} diff --git a/mod/mod-ci-github-service-data.hxx b/mod/mod-ci-github-service-data.hxx new file mode 100644 index 0000000..4d0af96 --- /dev/null +++ b/mod/mod-ci-github-service-data.hxx @@ -0,0 +1,92 @@ +// file : mod/mod-ci-github-serice-data.hxx -*- C++ -*- +// license : MIT; see accompanying LICENSE file + +#ifndef MOD_MOD_CI_GITHUB_SERVICE_DATA_HXX +#define MOD_MOD_CI_GITHUB_SERVICE_DATA_HXX + +#include +#include + +#include + +namespace brep +{ + // Service data associated with the tenant (corresponds to GH check suite). + // + // It is always a top-level JSON object and the first member is always the + // schema version. + + // Unsynchronized state means we were unable to (conclusively) notify + // GitHub about the last state transition (e.g., due to a transient + // network error). The "conclusively" part means that the notification may + // or may not have gone through. Note: node_id can be absent for the same + // reason. + // + struct check_run + { + string build_id; // Full build id. + optional node_id; // GitHub id. + build_state state; + bool state_synced; + + string + state_string () const + { + string r (to_string (*state)); + if (!state_synced) + r += "(unsynchronized)"; + return r; + } + }; + + struct service_data + { + // The data schema version. Note: must be first member in the object. + // + uint64_t version = 1; + + // Check suite-global data. + // + installation_access_token installation_access; + + uint64_t installation_id; + // @@ TODO Rename to repository_node_id. + // + string repository_id; // GitHub-internal opaque repository id. + + string head_sha; + + vector check_runs; + + // Return the check run with the specified build ID or nullptr if not + // found. + // + check_run* + find_check_run (const string& build_id); + + // Construct from JSON. + // + // Throw invalid_argument if the schema version is not supported. + // + explicit + service_data (const string& json); + + service_data (string iat_token, + timestamp iat_expires_at, + uint64_t installation_id, + string repository_id, + string head_sha); + + service_data () = default; + + // Serialize to JSON. + // + string + json () const; + }; + + ostream& + operator<< (ostream&, const service_data::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 22ba205..179eeba 100644 --- a/mod/mod-ci-github.cxx +++ b/mod/mod-ci-github.cxx @@ -329,77 +329,6 @@ namespace brep } } - // Service data associated with the tenant/check suite. - // - // It is always a top-level JSON object and the first member is always the - // schema version. - // - struct service_data - { - // The data schema version. Note: must be first member in the object. - // - uint64_t version = 1; - - // Check suite-global data. - // - installation_access_token installation_access; - - uint64_t installation_id; - // @@ TODO Rename to repository_node_id. - // - string repository_id; // GitHub-internal opaque repository id. - - string head_sha; - - // Absent state means we were unable to (conclusively) notify GitHub about - // the last state transition (e.g., due to a transient network error). The - // "conclusively" part means that the notification may or may not have - // gone through. Note: node_id can be absent for the same reason. - // - struct check_run - { - string build_id; // Full build id. - optional node_id; // GitHub id. - optional state; - - string - state_string () const - { - return state ? to_string (*state) : "null"; - } - }; - vector check_runs; - - // Return the check run with the specified build ID or nullptr if not - // found. - // - check_run* - find_check_run (const string& build_id); - - // Construct from JSON. - // - // Throw invalid_argument if the schema version is not supported. - // - explicit - service_data (const string& json); - - service_data (string iat_token, - timestamp iat_expires_at, - uint64_t installation_id, - string repository_id, - string head_sha); - - service_data () = default; - - // Serialize to JSON. - // - string - json () const; - }; - - ostream& - operator<< (ostream&, const service_data::check_run&); - bool ci_github:: handle_check_suite_request (check_suite_event cs) { @@ -453,887 +382,6 @@ namespace brep return true; } - // GraphQL serialization functions. - // - // The GraphQL spec: - // https://spec.graphql.org/ - // - // The GitHub GraphQL API reference: - // https://docs.github.com/en/graphql/reference/ - // - - // Check that a string is a valid GraphQL name. - // - // GraphQL names can contain only alphanumeric characters and underscores - // and cannot begin with a digit (so basically a C identifier). - // - // Return the name or throw invalid_argument if it is invalid. - // - static const string& - gq_name (const string& v) - { - if (v.empty () || digit (v[0])) - throw invalid_argument ("invalid GraphQL name: '" + v + '\''); - - for (char c: v) - { - if (!alnum (c) && c != '_') - { - throw invalid_argument ("invalid character in GraphQL name: '" + c + - '\''); - } - } - - return v; - } - - // Serialize a string to GraphQL. - // - // Return the serialized string or throw invalid_argument if the string is - // invalid. - // - static string - gq_str (const string& v) - { - // GraphQL strings are the same as JSON strings so we use the JSON - // serializer. - // - string b; - json::buffer_serializer s (b); - - try - { - s.value (v); - } - catch (const json::invalid_json_output&) - { - throw invalid_argument ("invalid GraphQL string: '" + v + '\''); - } - - return b; - } - - // Serialize an int to GraphQL. - // -#if 0 - static string - gq_int (uint64_t v) - { - string b; - json::buffer_serializer s (b); - s.value (v); - return b; - } -#endif - - // Serialize a boolean to GraphQL. - // - static inline string - gq_bool (bool v) - { - return v ? "true" : "false"; - } - - // Check that a string is a valid GraphQL enum value. - // - // GraphQL enum values can be any GraphQL name except for `true`, `false`, - // or `null`. - // - // Return the enum value or throw invalid_argument if it is invalid. - // - static const string& - gq_enum (const string& v) - { - if (v == "true" || v == "false" || v == "null") - throw invalid_argument ("invalid GraphQL enum value: '" + v + '\''); - - return gq_name (v); - } - - // Create a check_run name from a build. If the second argument is not - // NULL, return an abbreviated id if possible. - // - static string - 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; - } - - // Return the GitHub check run status corresponding to a build_state. - // - static const string& - to_string_gh (build_state st) - { - static const string sts[] {"QUEUED", "IN_PROGRESS", "COMPLETED"}; - - return sts[static_cast (st)]; - } - - // Return the build_state corresponding to a GitHub check run status - // string. Throw invalid_argument if the passed status was invalid. - // - static build_state - from_string_gh (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 + - '\''); - } - - // Serialize `createCheckRun` mutations for one or more builds to GraphQL. - // - static string - create_check_runs (const string& ri, // Repository ID - const string& hs, // Head SHA - const vector>& bs, - build_state st, - const tenant_service_base::build_hints* bh = nullptr) - { - ostringstream os; - - os << "mutation {" << '\n'; - - // Serialize a `createCheckRun` for each build. - // - for (size_t i (0); i != bs.size (); ++i) - { - const build& b (bs[i]); - - string al ("cr" + to_string (i)); // Field alias. - - // Check run name. - // - string nm (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' - << "})" << '\n' - // Specify the selection set (fields to be returned). - // - << "{" << '\n' - << " checkRun {" << '\n' - << " id," << '\n' - << " name," << '\n' - << " status" << '\n' - << " }" << '\n' - << "}" << '\n'; - } - - os << "}" << '\n'; - - return os.str (); - } - - // Serialize an `updateCheckRun` mutation for one build to GraphQL. - // - // @@ TODO Support conclusion, output, etc. - // - static string - update_check_run (const string& ri, // Repository ID - const string& ci, // Check run node_id - build_state st) - { - ostringstream os; - - os << "mutation {" << '\n' - << "cr0:updateCheckRun(input: {" << '\n' - << " checkRunId: " << gq_str (ci) << ',' << '\n' - << " repositoryId: " << gq_str (ri) << ',' << '\n' - << " status: " << gq_enum (to_string_gh (st)) << '\n' - << "})" << '\n' - // Specify the selection set (fields to be returned). - // - << "{" << '\n' - << " checkRun {" << '\n' - << " id," << '\n' - << " name," << '\n' - << " status" << '\n' - << " }" << '\n' - << "}" << '\n' - << "}" << '\n'; - - 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 - graphql_request (const string& o) - { - string b; - json::buffer_serializer s (b); - - s.begin_object (); - s.member ("query", o); - s.end_object (); - - return b; - } - - [[noreturn]] 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 errors comes before data in GitHub's responses. - // - static void - parse_graphql_response (json::parser& p, - function 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 - parse_check_runs_response (json::parser& p) - { - using event = json::event; - - vector r; - - parse_graphql_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 POST request to the GitHub API endpoint `ep`, parse GitHub's JSON - // response into `rs` (only for 200 codes), and return the HTTP status code. - // - // The endpoint `ep` should not have a leading slash. - // - // Pass additional HTTP headers in `hdrs`. For example: - // - // "HeaderName: header value" - // - // Throw invalid_argument if unable to parse the response headers, - // invalid_json_input (derived from invalid_argument) if unable to parse the - // response body, and system_error in other cases. - // - template - static uint16_t - github_post (T& rs, - const string& ep, - const strings& hdrs, - const string& body = "") - { - // Convert the header values to curl header option/value pairs. - // - strings hdr_opts; - - for (const string& h: hdrs) - { - hdr_opts.push_back ("--header"); - hdr_opts.push_back (h); - } - - // Run curl. - // - try - { - // Pass --include to print the HTTP status line (followed by the response - // headers) so that we can get the response status code. - // - // Suppress the --fail option which causes curl to exit with status 22 - // in case of an error HTTP response status code (>= 400) otherwise we - // can't get the status code. - // - // Note that butl::curl also adds --location to make curl follow redirects - // (which is recommended by GitHub). - // - // The API version `2022-11-28` is the only one currently supported. If - // the X-GitHub-Api-Version header is not passed this version will be - // chosen by default. - // - fdpipe errp (fdopen_pipe ()); // stderr pipe. - - curl c (path ("-"), // Read input from curl::out. - path ("-"), // Write response to curl::in. - process::pipe (errp.in.get (), move (errp.out)), - curl::post, - curl::flags::no_fail, - "https://api.github.com/" + ep, - "--no-fail", // Don't fail if response status code >= 400. - "--include", // Output response headers for status code. - "--header", "Accept: application/vnd.github+json", - "--header", "X-GitHub-Api-Version: 2022-11-28", - move (hdr_opts)); - - ifdstream err (move (errp.in)); - - // Parse the HTTP response. - // - uint16_t sc; // Status code. - try - { - // Note: re-open in/out so that they get automatically closed on - // exception. - // - ifdstream in (c.in.release (), fdstream_mode::skip); - ofdstream out (c.out.release ()); - - // Write request body to out. - // - if (!body.empty ()) - out << body; - out.close (); - - sc = curl::read_http_status (in).code; // May throw invalid_argument. - - // Parse the response body if the status code is in the 200 range. - // - if (sc >= 200 && sc < 300) - { - // Use endpoint name as input name (useful to have it propagated - // in exceptions). - // - json::parser p (in, ep /* name */); - rs = T (p); - } - - in.close (); - } - catch (const io_error& e) - { - // If the process exits with non-zero status, assume the IO error is due - // to that and fall through. - // - if (c.wait ()) - { - throw_generic_error ( - e.code ().value (), - (string ("unable to read curl stdout: ") + e.what ()).c_str ()); - } - } - catch (const json::invalid_json_input&) - { - // If the process exits with non-zero status, assume the JSON error is - // due to that and fall through. - // - if (c.wait ()) - throw; - } - - if (!c.wait ()) - { - string et (err.read_text ()); - throw_generic_error (EINVAL, - ("non-zero curl exit status: " + et).c_str ()); - } - - err.close (); - - return sc; - } - catch (const process_error& e) - { - throw_generic_error ( - e.code ().value (), - (string ("unable to execute curl:") + e.what ()).c_str ()); - } - catch (const io_error& e) - { - // Unable to read diagnostics from stderr. - // - throw_generic_error ( - e.code ().value (), - (string ("unable to read curl stderr : ") + e.what ()).c_str ()); - } - } - - // @@ TODO Pass error, trace in same order everywhere. - - // Fetch from GitHub the check run with the specified name (hints-shortened - // build ID). - // - // Return the check run or nullopt if no such check run exists. - // - // In case of error diagnostics will be issued and false returned in second. - // - // 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. - // - static pair, bool> - 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; - { - 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 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 ("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}; - } - - // 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. - // - static bool - mutate_check_runs (vector& crs, - const vector>& bs, - const string& iat, - string rq, - build_state st, - const basic_mark& error) noexcept - { - vector rcrs; - - try - { - // Response type which parses a GraphQL response containing multiple - // check_run objects. - // - struct resp - { - vector check_runs; // Received check runs. - - resp (json::parser& p) : check_runs (parse_check_runs_response (p)) {} - - resp () = default; - } rs; - - uint16_t sc (github_post (rs, - "graphql", // API Endpoint. - strings {"Authorization: Bearer " + iat}, - move (rq))); - - if (sc == 200) - { - rcrs = move (rs.check_runs); - - if (rcrs.size () == bs.size ()) - { - for (size_t i (0); i != rcrs.size (); ++i) - { - // Validate the check run in the response against the build. - // - const check_run& rcr (rcrs[i]); // Received check run. - const build& b (bs[i]); - - build_state rst (from_string_gh (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) << '\''; - - return false; // Fail because something is clearly very wrong. - } - else - { - service_data::check_run& cr (crs[i]); - - if (!cr.node_id) - cr.node_id = move (rcr.node_id); - - cr.state = from_string_gh (rcr.status); - } - } - - return true; - } - else - error << "unexpected number of check_run objects in response"; - } - else - error << "failed to update check run: 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 mutate check runs (errno=" << e.code () << "): " - << e.what (); - } - catch (const runtime_error& e) // From parse_check_runs_response(). - { - // GitHub response contained error(s) (could be ours or theirs at this - // point). - // - error << "unable to mutate check runs: " << e; - } - - return false; - } - - static bool - mutate_check_run (service_data::check_run& cr, - const vector>& bs, - const string& iat, - string rq, - build_state st, - const basic_mark& error) noexcept - { - vector crs {move (cr)}; - - bool r (mutate_check_runs (crs, bs, iat, move (rq), st, error)); - - cr = move (crs[0]); - - return r; - } - function (const tenant_service&)> ci_github:: build_queued (const tenant_service& ts, const vector& builds, diff --git a/mod/mod-ci-github.hxx b/mod/mod-ci-github.hxx index b628b2a..a6cd180 100644 --- a/mod/mod-ci-github.hxx +++ b/mod/mod-ci-github.hxx @@ -13,137 +13,8 @@ #include #include -namespace butl -{ - namespace json - { - class parser; - } -} - namespace brep { - // GitHub request/response types. - // - // Note that the GitHub REST and GraphQL APIs use different ID types and - // values. In the REST API they are usually integers (but sometimes - // strings!) whereas in GraphQL they are always strings (note: - // base64-encoded and opaque, not just the REST ID value as a string). - // - // In both APIs the ID field is called `id`, but REST responses and webhook - // events also contain the corresponding GraphQL object's ID in the - // `node_id` field. - // - // In the structures below we always use the RESP API/webhook names for ID - // fields. I.e., `id` always refers to the REST/webhook ID, and `node_id` - // always refers to the GraphQL ID. - // - // Note that having the below types directly in brep causes clashes (e.g., - // for the repository name). - // - namespace gh - { - namespace json = butl::json; - - // The "check_suite" object within a check_suite webhook event request. - // - struct check_suite - { - string node_id; - string head_branch; - string head_sha; - string before; - string after; - - explicit - check_suite (json::parser&); - - check_suite () = default; - }; - - struct check_run - { - string node_id; - string name; - string status; - - explicit - check_run (json::parser&); - - check_run () = default; - }; - - struct repository - { - string node_id; - string name; - string full_name; - string default_branch; - string clone_url; - - explicit - repository (json::parser&); - - repository () = default; - }; - - struct installation - { - uint64_t id; // Note: used for installation access token (REST API). - - explicit - installation (json::parser&); - - installation () = default; - }; - - // The check_suite webhook event request. - // - struct check_suite_event - { - string action; - gh::check_suite check_suite; - gh::repository repository; - gh::installation installation; - - explicit - check_suite_event (json::parser&); - - check_suite_event () = default; - }; - - struct installation_access_token - { - string token; - timestamp expires_at; - - explicit - installation_access_token (json::parser&); - - installation_access_token (string token, timestamp expires_at); - - installation_access_token () = default; - }; - - ostream& - operator<< (ostream&, const check_suite&); - - ostream& - operator<< (ostream&, const check_run&); - - ostream& - operator<< (ostream&, const repository&); - - ostream& - operator<< (ostream&, const installation&); - - ostream& - operator<< (ostream&, const check_suite_event&); - - ostream& - operator<< (ostream&, const installation_access_token&); - } - class ci_github: public handler, private ci_start, public tenant_service_build_queued, -- cgit v1.1