#!/bin/bash
# Read and write Gitea issues from a shell - the issue-side counterpart to
# gitea-pr, and deliberately its twin rather than its sibling: both are
# copied into the sandbox image as single self-contained files, and gitea-pr
# also lives in a dotfiles checkout, so a shared library would be a third
# thing to keep in step across three places. The duplicated config/token/curl
# preamble is pinned in both files by test/static.bats instead.
set -euo pipefail

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

# The marker saying an agent wrote the body is deliberately not here. It is
# applied by docker/forge-guard.sh, which shadows this command inside the
# sandbox, so that it reaches every forge and not only the ones whose CLI
# dejima happens to have written. Outside a sandbox this tool is being run by
# a person, and a person's body is not agent-generated.

function usage() {
    echo "Usage: gitea-issue list [--state open|closed|all] [--limit N]"
    echo "       gitea-issue view NUMBER"
    echo "       gitea-issue create --title TITLE [--body BODY | --body-file FILE]"
    echo "       gitea-issue edit NUMBER [--title TITLE] [--body BODY | --body-file FILE]"
    echo "       gitea-issue comment NUMBER (--body BODY | --body-file FILE)"
    echo "       gitea-issue close NUMBER"
    echo "       gitea-issue reopen NUMBER"
    echo ""
    echo "--body-file reads the body from FILE, or from stdin when FILE is '-'."
    echo "Prefer it over --body for anything with quotes, backticks or newlines."
    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. Every call site did this by hand in
# gitea-pr; there are six of them here, and six hand-rolled copies is where
# one of them quietly stops checking.
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
}

# Reads a body from a file, or from stdin for '-'. The point is to keep prose
# out of argv: a body composed on a command line has to survive shell quoting,
# and the things that break it - an apostrophe, a backtick, a $ - are exactly
# what English and code fences are made of. A file (or a heredoc into '-') has
# no such layer.
function read_body_file() {
    local file="$1"
    if [ "$file" = "-" ]; then
        cat
        return
    fi
    if [ ! -f "$file" ]; then
        echo "No such body file: $file" >&2
        exit 1
    fi
    cat "$file"
}

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-issue only supports this host" >&2
        exit 1
    fi
    owner_repo=$(echo "$remote_url" | sed -E 's#^[^@]+@[^:]+:##; s#\.git$##')
    echo "$owner_repo"
}

function require_number() {
    if ! [[ "${1:-}" =~ ^[0-9]+$ ]]; then
        echo "Expected an issue number, got '${1:-}'" >&2
        exit 1
    fi
}

# Sets `body` from --body/--body-file, rejecting both. Shared by create and
# comment so the two cannot drift into disagreeing about which one wins.
function parse_body_args() {
    body=""
    local body_file=""
    parsed_title=""
    while [ "$#" -gt 0 ]; do
        case "$1" in
            --title) parsed_title="$2"; shift 2 ;;
            --body) body="$2"; shift 2 ;;
            --body-file) body_file="$2"; shift 2 ;;
            *) echo "Unknown option: $1" >&2; usage; exit 1 ;;
        esac
    done
    # Rejected rather than ranked: silently preferring one would make a caller
    # that passed both ship a body it didn't mean to.
    if [ -n "$body" ] && [ -n "$body_file" ]; then
        echo "Pass --body or --body-file, not both" >&2
        exit 1
    fi
    if [ -n "$body_file" ]; then
        body="$(read_body_file "$body_file")"
    fi
}

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

subcommand="$1"
shift

case "$subcommand" in
    list|view|create|edit|comment|close|reopen)
        require_config
        require_token
        owner_repo=$(gitea_owner_repo)
        owner="${owner_repo%%/*}"
        repo="${owner_repo##*/}"
        ;;
    *)
        usage
        exit 1
        ;;
esac

issues_api="${GITEA_API}/repos/${owner}/${repo}/issues"

case "$subcommand" in
    list)
        state="open"
        limit="30"
        while [ "$#" -gt 0 ]; do
            case "$1" in
                --state) state="$2"; shift 2 ;;
                --limit) limit="$2"; shift 2 ;;
                *) echo "Unknown option: $1" >&2; usage; exit 1 ;;
            esac
        done
        case "$state" in
            open|closed|all) ;;
            *) echo "--state must be open, closed or all" >&2; exit 1 ;;
        esac
        if ! [[ "$limit" =~ ^[0-9]+$ ]]; then
            echo "--limit must be a number" >&2
            exit 1
        fi

        # type=issues is load-bearing: Gitea's issues endpoint returns pull
        # requests too, since a PR *is* an issue with a branch attached.
        # Without it every open PR shows up here as an issue, which reads as
        # a work list that is quietly twice as long as the real one.
        api_or_die "list issues" \
            "${issues_api}?state=${state}&type=issues&limit=${limit}"
        echo "$json" | jq -r '.[] | "#\(.number)\t[\(.state)]\t\(.title)\t(\(.comments) comments)"'
        ;;
    view)
        require_number "${1:-}"
        number="$1"

        api_or_die "fetch issue #${number}" "${issues_api}/${number}"
        echo "$json" | jq -r '"#\(.number) [\(.state)] \(.title)\n[\(.user.login) @ \(.created_at)]\n\(.body // "(no description)")\n"'

        echo "=== Comments ==="
        api_or_die "fetch comments for issue #${number}" "${issues_api}/${number}/comments"
        echo "$json" | jq -r '.[] | "[\(.user.login) @ \(.created_at)]\n\(.body)\n"'
        ;;
    create)
        parse_body_args "$@"
        if [ -z "$parsed_title" ]; then
            echo "gitea-issue create requires --title" >&2
            exit 1
        fi
        api_or_die "create issue" -X POST "$issues_api" \
            -H "Content-Type: application/json" \
            -d "$(jq -n --arg title "$parsed_title" --arg body "$body" \
                '{title: $title, body: $body}')"
        echo "$json" | jq -r '.html_url'
        ;;
    edit)
        require_number "${1:-}"
        number="$1"
        shift
        parse_body_args "$@"
        if [ -z "$parsed_title" ] && [ -z "$body" ]; then
            echo "gitea-issue edit requires --title, --body or --body-file" >&2
            exit 1
        fi

        # Only the fields actually given are sent: an omitted key leaves that
        # field alone, so editing a title cannot blank a body by accident.
        payload='{}'
        if [ -n "$parsed_title" ]; then
            payload=$(echo "$payload" | jq --arg t "$parsed_title" '. + {title: $t}')
        fi
        if [ -n "$body" ]; then
            payload=$(echo "$payload" | jq --arg b "$body" '. + {body: $b}')
        fi

        api_or_die "edit issue #${number}" -X PATCH "${issues_api}/${number}" \
            -H "Content-Type: application/json" -d "$payload"
        echo "$json" | jq -r '.html_url'
        ;;
    comment)
        require_number "${1:-}"
        number="$1"
        shift
        parse_body_args "$@"
        if [ -z "$body" ]; then
            echo "gitea-issue comment requires --body or --body-file" >&2
            exit 1
        fi
        api_or_die "comment on issue #${number}" -X POST "${issues_api}/${number}/comments" \
            -H "Content-Type: application/json" \
            -d "$(jq -n --arg body "$body" '{body: $body}')"
        echo "$json" | jq -r '.html_url'
        ;;
    close|reopen)
        require_number "${1:-}"
        number="$1"
        new_state="closed"
        [ "$subcommand" = "reopen" ] && new_state="open"

        api_or_die "${subcommand} issue #${number}" -X PATCH "${issues_api}/${number}" \
            -H "Content-Type: application/json" \
            -d "$(jq -n --arg state "$new_state" '{state: $state}')"
        echo "$json" | jq -r '"#\(.number) is now \(.state): \(.html_url)"'
        ;;
esac
