#!/bin/bash
# Read Gitea Actions runs and job logs from a shell - the CI-side counterpart
# to gitea-pr and gitea-issue. Self-contained like both of them rather than
# sharing a library: all three are copied into the image as single files, so
# a library would be another thing to keep in step. test/static.bats pins the
# duplicated config/token/curl preamble instead.
#
# It exists for the agent: CI is where the tests the sandbox cannot run
# actually run, and without this the only way to see a failure is for the
# operator to paste the log in.
#
# Nothing here touches /repos/{owner}/{repo}/actions/runs or the endpoints
# reached through it, however natural they look. Gitea gates that group
# behind reqOwner(), so a dedicated non-owner account - what a sandbox should
# have - is refused, and no scope grants ownership. The three sources used
# instead need only read access:
#
#   actions/tasks            one record per job, carrying its run number,
#                            head_sha, event and result
#   commits/<sha>/statuses   the same jobs as commit statuses, and the only
#                            place a job's index is visible
#   the web log route        the run view's own endpoint, which takes the
#                            same token header as the API
#
# The job index matters because it is what addresses a log, and it is not a
# field anywhere - only the tail of a status's target_url.
set -euo pipefail

GITEA_TOKEN_FILE="${GITEA_TOKEN_FILE:-$HOME/.config/gitea/token}"

function usage() {
    echo "Usage: gitea-actions runs [--limit N]"
    echo "       gitea-actions jobs [RUN]"
    echo "       gitea-actions logs [RUN] [--job NAME|INDEX] [--all] [--timestamps]"
    echo ""
    echo "RUN is either number a run has: the one 'runs' lists, or the one in"
    echo "its web URL (.../actions/runs/11387). They are different numbers on"
    echo "any instance that has run more than one workflow. Left out, RUN means"
    echo "the newest run for HEAD's commit - after a push, that is almost"
    echo "always the one being asked about."
    echo ""
    echo "'logs' prints the failed jobs of a run; --all prints every job, and"
    echo "--job picks one by name (a substring is enough) or by index."
    echo "Timestamps are stripped unless --timestamps is given."
    echo ""
    echo "Requires GITEA_SSH_HOST and GITEA_API set in the environment (e.g. in" \
         "your shell profile), and a token: GITEA_TOKEN if it is set, otherwise" \
         "the contents of $GITEA_TOKEN_FILE. Inside a dejima sandbox the" \
         "variable is the arrangement - the file is deliberately not there."
}

function require_config() {
    if [ -z "${GITEA_SSH_HOST:-}" ]; then
        echo "GITEA_SSH_HOST is not set. Add 'export GITEA_SSH_HOST=<gitea ssh hostname>'" \
             "(e.g. gitea.example.com) to ~/.profile.local" >&2
        exit 1
    fi
    if [ -z "${GITEA_API:-}" ]; then
        echo "GITEA_API is not set. Add 'export GITEA_API=https://<gitea host>/api/v1'" \
             "(e.g. https://gitea.example.com/api/v1) to ~/.profile.local" >&2
        exit 1
    fi
}

function require_token() {
    if [ -z "${GITEA_TOKEN:-}" ] && [ -f "$GITEA_TOKEN_FILE" ]; then
        GITEA_TOKEN=$(<"$GITEA_TOKEN_FILE")
    fi
    if [ -z "${GITEA_TOKEN:-}" ]; then
        if [ -n "${DEJIMA:-}" ]; then
            # In a sandbox the token arrives as GITEA_TOKEN, read host-side from
            # whatever the forge's token_file names. Writing the file suggested
            # below would satisfy this check and nothing else: the container is
            # disposable, and the operator's real credential is not in it. Say
            # what has to change instead, which is outside.
            echo "No Gitea token in this sandbox. It arrives as GITEA_TOKEN," \
                 "read on the host from the token_file the configured forge" \
                 "names - so there is nothing to create in here, and writing" \
                 "$GITEA_TOKEN_FILE would not help." >&2
            echo "" >&2
            echo "Most likely this session has no forge configured, or the" \
                 "configured one is not gitea. Ask the operator to check" \
                 "[core] forge and [forge.<name>] token_file." >&2
        else
            echo "No Gitea token found. Generate one in the Gitea web UI" \
                 "(Settings > Applications, scopes write:repository +" \
                 "write:issue) and save it to $GITEA_TOKEN_FILE (chmod 600)," \
                 "or set GITEA_TOKEN in the environment." >&2
        fi
        exit 1
    fi
}

# Feeds the auth header to curl as a config on stdin rather than as an
# argument. Command lines are world-readable through /proc, so `-H
# "Authorization: token $GITEA_TOKEN"` exposes the token to every other
# process on the host for as long as the request runs - and to anything that
# scrapes `ps` output, including the agents this repo exists to sandbox.
# printf is a bash builtin, so the token never lands in another argv.
function gitea_api() {
    printf 'header = "Authorization: token %s"\n' "$GITEA_TOKEN" |
        curl -sS -w '\n%{http_code}' -K - "$@"
}

# Splits gitea_api's combined output into the globals `json` and `http_code`
# and fails with the body on any non-2xx.
function api_or_die() {
    local what="$1"; shift
    local response
    response=$(gitea_api "$@")
    http_code=$(echo "$response" | tail -1)
    json=$(echo "$response" | sed '$d')
    if [ "$http_code" -lt 200 ] || [ "$http_code" -ge 300 ]; then
        echo "Failed to $what (HTTP $http_code):" >&2
        echo "$json" >&2
        exit 1
    fi
}

# Gitea paginates its list endpoints, and a caller that reads one page is
# correct only while the list is short. Duplicated from gitea-pr rather than
# shared, for the reason gitea-issue's header gives: these are self-contained
# files copied into the image, and a library would be a third thing to keep in
# step. Sets `json` to the concatenation of every page.
#
# Pages are followed until one comes back short. PAGE_MAX bounds the loop
# rather than trusting that, since this runs unattended.
PAGE_LIMIT=100
PAGE_MAX=40
function api_all_pages() {
    local what="$1" url="$2" sep page=1 count acc='[]'
    case "$url" in *\?*) sep='&' ;; *) sep='?' ;; esac
    while [ "$page" -le "$PAGE_MAX" ]; do
        api_or_die "$what" "${url}${sep}limit=${PAGE_LIMIT}&page=${page}"
        count=$(echo "$json" | jq 'if type == "array" then length else 0 end')
        acc=$(jq -n --argjson a "$acc" --argjson b "$json" \
            '$a + (if ($b | type) == "array" then $b else [] end)')
        [ "$count" -eq "$PAGE_LIMIT" ] || break
        page=$((page + 1))
    done
    if [ "$page" -gt "$PAGE_MAX" ]; then
        echo "warning: stopped reading $what after $PAGE_MAX pages - some may be missing" >&2
    fi
    json="$acc"
}

function gitea_owner_repo() {
    local remote_url host owner_repo
    remote_url=$(git remote get-url origin 2>/dev/null) || {
        echo "No 'origin' remote found in this repository" >&2
        exit 1
    }
    host=$(echo "$remote_url" | sed -E 's#^[^@]+@([^:]+):.*#\1#')
    if [ "$host" != "$GITEA_SSH_HOST" ]; then
        echo "origin remote ($remote_url) is not on $GITEA_SSH_HOST; gitea-actions only supports this host" >&2
        exit 1
    fi
    owner_repo=$(echo "$remote_url" | sed -E 's#^[^@]+@[^:]+:##; s#\.git$##')
    echo "$owner_repo"
}

# Sets `tasks_json`. The list pages by TASK - one record per job - so a run
# costs as many records as it has jobs, and `limit` is ignored unless `page`
# comes with it. Taken as one page, sized for a few dozen runs of a ten-job
# workflow; a lookup that falls off the end says which it was.
function fetch_tasks() {
    api_or_die "list runs" \
        "${GITEA_API}/repos/${owner}/${repo}/actions/tasks?page=1&limit=$TASK_SCAN"
    tasks_json="$json"
}

# Sets `run_number`, `run_web_id` and `run_sha`. With no number, HEAD's
# commit: a run list is not what someone who just pushed is asking for.
# Matched on head_sha rather than on branch because a pull_request run reports
# no branch at all - its ref is refs/pull/N/head.
#
# A run has TWO numbers and they are not interchangeable. `run_number` is the
# per-workflow counter this tool lists and the web UI shows (225). The URLs -
# a commit status's target_url, and the log endpoint - carry a different,
# repo-wide index (11387), which is the number in a run's web address and the
# only one those paths answer to. They started out equal on a fresh instance
# and drift apart for good the first time a second workflow runs, which is why
# filtering statuses by run_number silently matched nothing here: every job of
# every run was discarded, and `jobs` reported a run with no jobs at all.
#
# Both are accepted as $1, because this tool has printed one and its own usage
# has described the other. run_number wins a tie: it is the smaller space, so
# an argument that is both is far likelier to be the one the list just showed.
function resolve_run() {
    local want="${1:-}" sha entry
    fetch_tasks
    if [ -n "$want" ]; then
        entry=$(echo "$tasks_json" | jq -c --arg w "$want" '
            [.workflow_runs[] | select((.run_number | tostring) == $w)] as $bynum
            | [.workflow_runs[] | select((.url | split("/") | last) == $w)] as $byweb
            | (($bynum + $byweb)[0]) // empty')
        if [ -z "$entry" ]; then
            echo "No run $want in the last $TASK_SCAN job records (see 'gitea-actions runs')." >&2
            echo "Both the number in the run list and the one in its web URL work." >&2
            exit 1
        fi
    else
        sha=$(git rev-parse HEAD 2>/dev/null) || {
            echo "Not in a git repository, so there is no HEAD to look up - pass a run number" >&2
            exit 1
        }
        entry=$(echo "$tasks_json" | jq -c --arg sha "$sha" \
            'first(.workflow_runs[] | select(.head_sha == $sha)) // empty')
        if [ -z "$entry" ]; then
            echo "No run for HEAD (${sha:0:8}) in the last $TASK_SCAN job records - unpushed, or still queued?" >&2
            exit 1
        fi
    fi
    run_number=$(echo "$entry" | jq -r '.run_number')
    run_sha=$(echo "$entry" | jq -r '.head_sha')
    run_web_id=$(echo "$entry" | jq -r '.url | split("/") | last')
    if [ -z "$run_web_id" ] || [ "$run_web_id" = "null" ]; then
        echo "Run $run_number reports no web URL, so there is no id to address its" >&2
        echo "statuses or logs by. This is a Gitea API shape this tool does not know." >&2
        exit 1
    fi
}

# Sets `jobs_json` for the resolved run, from the commit's statuses - the one
# place a job's index is visible to a token that does not own the repo.
#
# A status is written more than once per job (queued, then finished) and the
# list arrives newest first, so the newest per context wins. `context` is
# "<workflow> / <job> (<event>)"; the job name is pulled out of it for
# display, and matching keeps the whole string so either form works. Filtered
# by run number as well as by commit, since a re-run puts a second run's
# statuses on the same commit.
function fetch_jobs() {
    # Every page, not the first hundred. A job writes at least two statuses
    # and a re-run stacks another set on the same commit, all BEFORE the
    # run-number filter below - so on a re-run of a workflow with a dozen jobs
    # the requested run's own statuses fall off page one. A failed job going
    # missing that way is not a visible error: it leaves `logs` printing
    # "all N jobs succeeded" to an agent that will act on it.
    api_all_pages "list jobs for run $run_number" \
        "${GITEA_API}/repos/${owner}/${repo}/commits/${run_sha}/statuses"
    # $rn is the web id, not run_number: target_url carries the former. See
    # resolve_run for why they differ.
    jobs_json=$(echo "$json" | jq --arg rn "$run_web_id" '
        [.[] | select(.target_url | test("/actions/runs/" + $rn + "/jobs/[0-9]+$"))]
        | group_by(.context) | map(first)
        | map({
            index: (.target_url | split("/") | last | tonumber),
            name: (.context | sub("^[^/]+/ *"; "") | sub(" *\\([^()]*\\)$"; "")),
            context,
            result: .status
          })
        | sort_by(.index)')
    if [ "$(echo "$jobs_json" | jq 'length')" -eq 0 ]; then
        echo "Run $run_number (${run_sha:0:8}) reports no jobs." >&2
        echo "Gitea publishes a run's jobs as statuses on its commit, and this" >&2
        echo "commit has none for that run - so there is nothing to address a log by." >&2
        exit 1
    fi
}

function print_job_log() {
    local index="$1" name="$2" result="$3"
    echo "=== $name ($result) ==="
    api_or_die "fetch logs for job $name" \
        "${GITEA_WEB}/${owner}/${repo}/actions/runs/${run_web_id}/jobs/${index}/logs"
    if [ "$KEEP_TIMESTAMPS" = 1 ]; then
        echo "$json"
    else
        # Every line carries an RFC3339 stamp from the runner. Useful for
        # timing a hang, pure noise for reading a failure, and this output
        # is usually going into an agent's context.
        echo "$json" | sed -E 's/^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9:.]+Z //'
    fi
    echo
}

TASK_SCAN=600
KEEP_TIMESTAMPS=0

if [ "$#" -lt 1 ]; then
    usage
    exit 1
fi

subcommand="$1"
shift

case "$subcommand" in
    runs|jobs|logs)
        require_config
        require_token
        owner_repo=$(gitea_owner_repo)
        owner="${owner_repo%%/*}"
        repo="${owner_repo##*/}"
        # Logs come from the run view's own endpoint rather than the API, so
        # the web root is needed as well. Derived from GITEA_API because that
        # is the only address the caller configures.
        GITEA_WEB="${GITEA_API%/api/v1}"
        if [ "$GITEA_WEB" = "$GITEA_API" ]; then
            echo "GITEA_API ($GITEA_API) does not end in /api/v1, so the web root" >&2
            echo "cannot be derived from it - logs are read from a non-API route." >&2
            exit 1
        fi
        ;;
    *)
        usage
        exit 1
        ;;
esac

case "$subcommand" in
    runs)
        limit=20
        while [ "$#" -gt 0 ]; do
            case "$1" in
                --limit) limit="$2"; shift 2 ;;
                *) echo "Unknown option: $1" >&2; usage; exit 1 ;;
            esac
        done
        fetch_tasks
        # One record per job, so a run appears once for each job it has.
        # Folded here into a row per run, whose result is the worst of them:
        # a run with one failed job is a failed run, and reporting it as
        # seven successes and a failure is not what the caller asked.
        echo "$tasks_json" | jq -r --argjson limit "$limit" '
            [.workflow_runs[]] | group_by(.run_number)
            | map({
                run: .[0].run_number,
                event: .[0].event,
                sha: (.[0].head_sha[0:8]),
                title: .[0].display_title,
                # "skipped" is a finished job. Counting it as unfinished, as
                # anything-but-success did, leaves a run reading in_progress
                # for good. Anything else unrecognised still reads
                # in_progress, which is the safe direction for a status this
                # does not know.
                result: (if   any(.[]; .status == "failure")   then "failure"
                         elif any(.[]; .status == "cancelled") then "cancelled"
                         elif any(.[]; .status as $s | ["success", "skipped"] | index($s) | not)
                              then "in_progress"
                         else "success" end)
              })
            | sort_by(.run) | reverse | .[0:$limit] | .[]
            | [(.run | tostring), .result, .event, .sha, .title] | @tsv' |
        while IFS=$'\t' read -r run result event sha title; do
            printf '%-5s %-11s %-20s %-8s %s\n' "$run" "$result" "$event" "$sha" "$title"
        done
        ;;
    jobs)
        run_arg=""
        if [[ "${1:-}" =~ ^[0-9]+$ ]]; then run_arg="$1"; shift; fi
        if [ "$#" -gt 0 ]; then echo "Unknown option: $1" >&2; usage; exit 1; fi
        resolve_run "$run_arg"
        fetch_jobs
        echo "run $run_number:"
        echo "$jobs_json" | jq -r '.[] | [(.index | tostring), .result, .name] | @tsv' |
        while IFS=$'\t' read -r index result name; do
            printf '%-3s %-11s %s\n' "$index" "$result" "$name"
        done
        ;;
    logs)
        run_arg=""
        job_want=""
        want_all=0
        if [[ "${1:-}" =~ ^[0-9]+$ ]]; then run_arg="$1"; shift; fi
        while [ "$#" -gt 0 ]; do
            case "$1" in
                --job) job_want="$2"; shift 2 ;;
                --all) want_all=1; shift ;;
                --timestamps) KEEP_TIMESTAMPS=1; shift ;;
                *) echo "Unknown option: $1" >&2; usage; exit 1 ;;
            esac
        done
        resolve_run "$run_arg"
        fetch_jobs

        if [ -n "$job_want" ]; then
            # An index is exact; anything else matches the name loosely, so
            # `--job host-docker` finds "host-docker" and `--job static`
            # finds "sandbox (static)".
            if [[ "$job_want" =~ ^[0-9]+$ ]]; then
                selected=$(echo "$jobs_json" | jq --argjson i "$job_want" '[.[] | select(.index == $i)]')
            else
                selected=$(echo "$jobs_json" | jq --arg n "$job_want" '[.[] | select(.context | contains($n))]')
            fi
            if [ "$(echo "$selected" | jq 'length')" -eq 0 ]; then
                echo "No job matching '$job_want' in run $run_number:" >&2
                echo "$jobs_json" | jq -r '.[] | "  \(.index)  \(.name)"' >&2
                exit 1
            fi
        elif [ "$want_all" = 1 ]; then
            selected="$jobs_json"
        else
            selected=$(echo "$jobs_json" | jq '[.[] | select(.result != "success")]')
            if [ "$(echo "$selected" | jq 'length')" -eq 0 ]; then
                echo "run $run_number: all $(echo "$jobs_json" | jq 'length') jobs succeeded (--all for their logs anyway)" >&2
                exit 0
            fi
        fi

        while IFS=$'\t' read -r index name result; do
            print_job_log "$index" "$name" "$result"
        done < <(echo "$selected" | jq -r '.[] | [(.index | tostring), .name, .result] | @tsv')
        ;;
esac
