Skip to main content

src/version_bump@github_api.erl

-module(version_bump@github_api).
-compile([no_auto_import, nowarn_unused_vars, nowarn_unused_function, nowarn_nomatch, inline]).
-define(FILEPATH, "src/version_bump/github_api.gleam").
-export([build_release_payload/5, create_release/8, parse_repo_url/1]).

-if(?OTP_RELEASE >= 27).
-define(MODULEDOC(Str), -moduledoc(Str)).
-define(DOC(Str), -doc(Str)).
-else.
-define(MODULEDOC(Str), -compile([])).
-define(DOC(Str), -compile([])).
-endif.

?MODULEDOC(
    " Minimal GitHub REST client used by the GitHub publish plugin.\n"
    "\n"
    " Pure request-building and URL-parsing helpers are separated from the\n"
    " effectful `create_release`, which actually performs the HTTP call. This\n"
    " keeps the parsing/serialisation logic unit-testable without a network.\n"
).

-file("src/version_bump/github_api.gleam", 27).
?DOC(
    " Serialise the JSON body for a create-release request.\n"
    "\n"
    " PURE: the `POST` payload for `api.github.com/repos/{owner}/{repo}/releases`.\n"
).
-spec build_release_payload(binary(), binary(), binary(), boolean(), binary()) -> binary().
build_release_payload(Tag, Name, Body, Prerelease, Target) ->
    _pipe = gleam@json:object(
        [{<<"tag_name"/utf8>>, gleam@json:string(Tag)},
            {<<"name"/utf8>>, gleam@json:string(Name)},
            {<<"body"/utf8>>, gleam@json:string(Body)},
            {<<"prerelease"/utf8>>, gleam@json:bool(Prerelease)},
            {<<"target_commitish"/utf8>>, gleam@json:string(Target)}]
    ),
    gleam@json:to_string(_pipe).

-file("src/version_bump/github_api.gleam", 234).
?DOC(" Parse the `html_url` field out of a GitHub release JSON response, if present.\n").
-spec parse_html_url(binary()) -> gleam@option:option(binary()).
parse_html_url(Body) ->
    Decoder = begin
        gleam@dynamic@decode:field(
            <<"html_url"/utf8>>,
            {decoder, fun gleam@dynamic@decode:decode_string/1},
            fun(Url) -> gleam@dynamic@decode:success(Url) end
        )
    end,
    case gleam@json:parse(Body, Decoder) of
        {ok, Url@1} ->
            {some, Url@1};

        {error, _} ->
            none
    end.

-file("src/version_bump/github_api.gleam", 245).
-spec http_error_to_string(gleam@httpc:http_error()) -> binary().
http_error_to_string(Err) ->
    case Err of
        invalid_utf8_response ->
            <<"GitHub API returned a non-UTF-8 response"/utf8>>;

        response_timeout ->
            <<"GitHub API request timed out"/utf8>>;

        {failed_to_connect, _, _} ->
            <<"Failed to connect to the GitHub API"/utf8>>
    end.

-file("src/version_bump/github_api.gleam", 108).
?DOC(" Build the `httpc` request from primitives (Erlang target only).\n").
-spec build_erlang_request(binary(), binary(), binary()) -> {ok,
        gleam@http@request:request(binary())} |
    {error, binary()}.
build_erlang_request(Url, Token, Body) ->
    case gleam@http@request:to(Url) of
        {ok, Req} ->
            {ok,
                begin
                    _pipe = Req,
                    _pipe@1 = gleam@http@request:set_method(_pipe, post),
                    _pipe@2 = gleam@http@request:set_body(_pipe@1, Body),
                    _pipe@3 = gleam@http@request:set_header(
                        _pipe@2,
                        <<"authorization"/utf8>>,
                        <<"Bearer "/utf8, Token/binary>>
                    ),
                    _pipe@4 = gleam@http@request:set_header(
                        _pipe@3,
                        <<"accept"/utf8>>,
                        <<"application/vnd.github+json"/utf8>>
                    ),
                    _pipe@5 = gleam@http@request:set_header(
                        _pipe@4,
                        <<"content-type"/utf8>>,
                        <<"application/json"/utf8>>
                    ),
                    _pipe@6 = gleam@http@request:set_header(
                        _pipe@5,
                        <<"x-github-api-version"/utf8>>,
                        <<"2022-11-28"/utf8>>
                    ),
                    gleam@http@request:set_header(
                        _pipe@6,
                        <<"user-agent"/utf8>>,
                        <<"version_bump"/utf8>>
                    )
                end};

        {error, _} ->
            {error, <<"invalid GitHub API URL: "/utf8, Url/binary>>}
    end.

-file("src/version_bump/github_api.gleam", 96).
?DOC(
    " Perform the POST and yield `#(status_code, body)`. A status of `0` signals a\n"
    " transport-level failure, with the message in the body slot.\n"
    "\n"
    " This function has a Gleam body (the Erlang/`httpc` implementation, also used\n"
    " by any target without an external) and a JavaScript `@external` that uses\n"
    " `fetch`. That is how one call site stays target-agnostic while the actual I/O\n"
    " is synchronous on the BEAM and promise-based on Node.\n"
).
-spec send(binary(), binary(), binary()) -> version_bump@task:task({integer(),
    binary()}).
send(Url, Token, Body) ->
    case build_erlang_request(Url, Token, Body) of
        {error, Message} ->
            version_bump_task_ffi:resolve({0, Message});

        {ok, Req} ->
            case gleam@httpc:send(Req) of
                {ok, Resp} ->
                    version_bump_task_ffi:resolve(
                        {erlang:element(2, Resp), erlang:element(4, Resp)}
                    );

                {error, Err} ->
                    version_bump_task_ffi:resolve(
                        {0, http_error_to_string(Err)}
                    )
            end
    end.

-file("src/version_bump/github_api.gleam", 51).
?DOC(
    " Create a GitHub release and return the resulting `Release`, asynchronously.\n"
    "\n"
    " The HTTP send is cross-target via `send`: on Erlang it uses `httpc`\n"
    " synchronously; on JavaScript it uses `fetch` (a real promise). Both yield a\n"
    " `Task(#(status, body))`, which is mapped here into a `Release` (parsing\n"
    " `html_url`), a non-2xx `NetworkError`, or a transport `NetworkError`\n"
    " (signalled as status `0`).\n"
).
-spec create_release(
    binary(),
    binary(),
    binary(),
    binary(),
    binary(),
    binary(),
    boolean(),
    binary()
) -> version_bump@task:task({ok, version_bump@release:release()} |
    {error, version_bump@error:release_error()}).
create_release(Token, Owner, Repo, Tag, Name, Body, Prerelease, Target) ->
    Url = <<<<<<<<<<<<"https://"/utf8, "api.github.com"/utf8>>/binary,
                        "/repos/"/utf8>>/binary,
                    Owner/binary>>/binary,
                "/"/utf8>>/binary,
            Repo/binary>>/binary,
        "/releases"/utf8>>,
    Payload = build_release_payload(Tag, Name, Body, Prerelease, Target),
    version_bump_task_ffi:map(
        send(Url, Token, Payload),
        fun(Outcome) ->
            {Status, Resp_body} = Outcome,
            case Status of
                0 ->
                    {error,
                        {network_error,
                            <<"Failed to reach the GitHub API: "/utf8,
                                Resp_body/binary>>}};

                S when (S >= 200) andalso (S < 300) ->
                    {ok,
                        {release,
                            Name,
                            parse_html_url(Resp_body),
                            Tag,
                            Tag,
                            none,
                            <<"github"/utf8>>}};

                S@1 ->
                    {error,
                        {network_error,
                            <<<<<<"GitHub API responded with status "/utf8,
                                        (erlang:integer_to_binary(S@1))/binary>>/binary,
                                    ": "/utf8>>/binary,
                                Resp_body/binary>>}}
            end
        end
    ).

-file("src/version_bump/github_api.gleam", 226).
-spec first_segment(binary()) -> binary().
first_segment(S) ->
    case gleam@string:split_once(S, <<"/"/utf8>>) of
        {ok, {Head, _}} ->
            Head;

        {error, _} ->
            S
    end.

-file("src/version_bump/github_api.gleam", 212).
-spec drop_trailing_slash(binary()) -> binary().
drop_trailing_slash(S) ->
    case gleam_stdlib:string_ends_with(S, <<"/"/utf8>>) of
        true ->
            drop_trailing_slash(gleam@string:drop_end(S, 1));

        false ->
            S
    end.

-file("src/version_bump/github_api.gleam", 219).
-spec strip_git_suffix(binary()) -> binary().
strip_git_suffix(S) ->
    case gleam_stdlib:string_ends_with(S, <<".git"/utf8>>) of
        true ->
            gleam@string:drop_end(S, 4);

        false ->
            S
    end.

-file("src/version_bump/github_api.gleam", 205).
-spec drop_leading_slash(binary()) -> binary().
drop_leading_slash(S) ->
    case S of
        <<"/"/utf8, Rest/binary>> ->
            drop_leading_slash(Rest);

        _ ->
            S
    end.

-file("src/version_bump/github_api.gleam", 193).
?DOC(" Strip an optional `user@` prefix from a host portion (e.g. `git@github.com`).\n").
-spec strip_userinfo(binary()) -> binary().
strip_userinfo(Rest) ->
    case gleam@string:split_once(Rest, <<"@"/utf8>>) of
        {ok, {Before, After}} ->
            case gleam_stdlib:contains_string(Before, <<"/"/utf8>>) of
                true ->
                    Rest;

                false ->
                    After
            end;

        {error, _} ->
            Rest
    end.

-file("src/version_bump/github_api.gleam", 137).
?DOC(
    " Extract `(owner, repo)` from an HTTPS or `git@` GitHub remote URL.\n"
    "\n"
    " PURE. Handles the common forms:\n"
    "   - `https://github.com/owner/repo.git`\n"
    "   - `https://github.com/owner/repo`\n"
    "   - `git@github.com:owner/repo.git`\n"
    "   - `ssh://git@github.com/owner/repo.git`\n"
    " The trailing `.git` and any trailing slash are stripped.\n"
).
-spec parse_repo_url(binary()) -> {ok, {binary(), binary()}} |
    {error, version_bump@error:release_error()}.
parse_repo_url(Url) ->
    Trimmed = gleam@string:trim(Url),
    Without_prefix = case Trimmed of
        <<"git+"/utf8, Rest/binary>> ->
            Rest;

        Other ->
            Other
    end,
    Remainder = case Without_prefix of
        <<"https://"/utf8, Rest@1/binary>> ->
            strip_userinfo(Rest@1);

        <<"http://"/utf8, Rest@2/binary>> ->
            strip_userinfo(Rest@2);

        <<"ssh://"/utf8, Rest@3/binary>> ->
            strip_userinfo(Rest@3);

        <<"git://"/utf8, Rest@4/binary>> ->
            strip_userinfo(Rest@4);

        <<"git@"/utf8, Rest@5/binary>> ->
            Rest@5;

        Other@1 ->
            Other@1
    end,
    Path = case gleam@string:split_once(Remainder, <<":"/utf8>>) of
        {ok, {_, After}} ->
            After;

        {error, _} ->
            case gleam@string:split_once(Remainder, <<"/"/utf8>>) of
                {ok, {_, After@1}} ->
                    After@1;

                {error, _} ->
                    Remainder
            end
    end,
    Path@1 = drop_leading_slash(Path),
    Path@2 = strip_git_suffix(Path@1),
    Path@3 = drop_trailing_slash(Path@2),
    case gleam@string:split_once(Path@3, <<"/"/utf8>>) of
        {ok, {Owner, Repo}} ->
            case {Owner, Repo} of
                {<<""/utf8>>, _} ->
                    {error,
                        {network_error,
                            <<"Could not parse owner/repo from URL: "/utf8,
                                Url/binary>>}};

                {_, <<""/utf8>>} ->
                    {error,
                        {network_error,
                            <<"Could not parse owner/repo from URL: "/utf8,
                                Url/binary>>}};

                {_, _} ->
                    Repo@1 = first_segment(Repo),
                    case Repo@1 of
                        <<""/utf8>> ->
                            {error,
                                {network_error,
                                    <<"Could not parse owner/repo from URL: "/utf8,
                                        Url/binary>>}};

                        _ ->
                            {ok, {Owner, Repo@1}}
                    end
            end;

        {error, _} ->
            {error,
                {network_error,
                    <<"Could not parse owner/repo from URL: "/utf8, Url/binary>>}}
    end.