#!/bin/sh
# script/ci-mark-superseded: record an honest status on commits whose CI
# run Gitea cancelled because a newer commit landed on the same branch.
# Gitea writes `failure` / "Has been cancelled" for such a run, which
# reads as a test result on a commit nothing ever tested. Cancellation is
# unconditional server-side for push events, so the superseding run
# rewrites those statuses to `failure` with a description that says the
# commit was never tested. `skipped` cannot be used: Gitea's combined
# status folds `skipped` into `success`, so a never-tested commit would
# report green. Genuine failures and successes are never touched.
#
# Called by the Gitea Actions workflow, which supplies GITHUB_API_URL,
# GITHUB_REPOSITORY, GITHUB_SHA, GITHUB_WORKFLOW, GITHUB_JOB,
# GITHUB_EVENT_NAME and GITEA_TOKEN. ANCESTOR_LIMIT (default 20) caps how
# far back the walk looks; a value that is set but not a positive integer
# aborts rather than silently disabling the walk.
set -eu

SUPERSEDED_DESC='Superseded by a newer commit; never tested'

# Gitea builds the commit-status context as
# "<workflow name> / <job name> (<event>)", so derive it rather than
# hardcoding the result.
#
# The derivation is deliberately not byte-exact with Gitea's own rule and
# must not be "fixed" into a silent fallback. Gitea uses the job's `name:`
# (falling back to the job id) and the workflow's `name:` (falling back to
# the workflow filename), while the runner exports GITHUB_JOB as the job
# *id* and GITHUB_WORKFLOW as the parsed workflow `name:`. So giving the
# job a display `name:`, or dropping the workflow's `name:`, makes the
# derived context stop matching --- and require_own_context below then
# turns every push red with a message. That loud failure is the point
# (https://git.eeqj.de/sneak/webhooker/issues/147 item 2); guessing at a
# fallback would restore the silent no-op it replaced.
context() {
    printf '%s / %s (%s)' \
        "$GITHUB_WORKFLOW" "$GITHUB_JOB" "$GITHUB_EVENT_NAME"
}

# ANCESTOR_LIMIT is a documented knob, so a value that is set but
# unusable must fail loudly instead of defaulting
# (https://git.eeqj.de/sneak/webhooker/issues/80). Passing it straight to
# git would print `fatal: not an integer` into a discarded exit status
# and mark nothing.
ancestor_limit() {
    # `-` and not `:-`: an explicitly empty value is set-but-unusable
    # config, so it aborts like any other bad value rather than silently
    # running at the default.
    _limit="${ANCESTOR_LIMIT-20}"
    case "$_limit" in
    '' | *[!0-9]* | 0*)
        echo "ANCESTOR_LIMIT must be a positive integer," \
            "got '${_limit}'" >&2

        return 1
        ;;
    esac
    printf '%s' "$_limit"
}

# The status Gitea created for this very job proves which context string
# it uses. If the derived one is missing, the workflow or the job was
# renamed and the match below would silently stop firing, restoring the
# false-red bug with no signal. Fail loudly instead.
require_own_context() {
    if ! _body="$(curl -sf --retry 3 --retry-delay 2 --max-time 30 \
        "${1}/commits/${GITHUB_SHA}/status")"; then
        echo "cannot read commit statuses for ${GITHUB_SHA}" >&2
        return 1
    fi
    _found="$(printf '%s' "$_body" | jq -r '(.statuses // [])[].context')"
    if printf '%s\n' "$_found" | grep -qxF "$2"; then
        return 0
    fi
    echo "no commit status with context '${2}' on ${GITHUB_SHA}:" >&2
    echo "workflow or job renamed? contexts present:" >&2
    printf '%s\n' "$_found" >&2
    return 1
}

# Latest status for our context on a commit, as "state|description".
# The read is retried and bounded, and a read that still fails aborts the
# step: a laundered commit that cannot be read is not the same as one
# with nothing to do, and piping curl into jq would discard the
# difference.
status_of() {
    if ! _sbody="$(curl -sf --retry 3 --retry-delay 2 --max-time 30 \
        "${1}/commits/${2}/status")"; then
        echo "cannot read commit statuses for ${2}" >&2

        return 1
    fi
    printf '%s' "$_sbody" | jq -r --arg c "$3" \
        '[(.statuses // [])[] | select(.context == $c)][0] // empty
         | "\(.status)|\(.description)"'
}

mark_superseded() {
    curl -sf -X POST "${1}/statuses/${2}" \
        -H "Authorization: token ${GITEA_TOKEN}" \
        -H 'Content-Type: application/json' \
        -d "$(jq -nc --arg c "$3" --arg d "$SUPERSEDED_DESC" \
            '{context: $c, state: "failure", description: $d}')" \
        >/dev/null
}

main() {
    _api="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}"
    _ctx="$(context)"

    _limit="$(ancestor_limit)"

    require_own_context "$_api" "$_ctx"

    # A shallow clone cannot resolve the parent, so it looks exactly like
    # a root commit to rev-parse below and would exit 0 having walked
    # nothing (or, at depth > 1, only the ancestors that happen to be
    # present). The workflow checks out with `fetch-depth: 0`; verify
    # that here rather than depend on it silently.
    if [ "$(git rev-parse --is-shallow-repository)" = 'true' ]; then
        echo "shallow repository: the ancestor walk needs full history" >&2

        return 1
    fi

    # A root commit legitimately has no ancestors and is not an error.
    # A SHA this repository does not have lands here too, since its
    # parent is equally unresolvable, but require_own_context above has
    # already aborted on the 404 for it. The walk itself carries no
    # `|| true`, so a rev-list failure aborts.
    if ! git rev-parse -q --verify "${GITHUB_SHA}^" >/dev/null; then
        echo "no ancestor of ${GITHUB_SHA} to check"

        return 0
    fi

    _walk="$(git rev-list --max-count="$_limit" "${GITHUB_SHA}^")"

    for _sha in $_walk; do
        _latest="$(status_of "$_api" "$_sha" "$_ctx")"
        # A run that was cancelled, or one an earlier revision of this
        # script laundered into `skipped`. Anything else stands.
        case "$_latest" in
        'failure|Has been cancelled' | "skipped|${SUPERSEDED_DESC}") ;;
        *) continue ;;
        esac
        mark_superseded "$_api" "$_sha" "$_ctx"
        echo "marked superseded: ${_sha}"
    done
}

main "$@"
