#!/bin/bash # issue-comment.sh - Add a comment to an issue on GitHub or Gitea # Usage: issue-comment.sh -i -c [--login ] # # tea v0.11.1 defines no `comment` subcommand under `tea issue` (or `tea pr`); # the correct invocation is the TOP-LEVEL `tea comment ` form. # Calling the non-existent `tea issue comment ...` form does not error — tea # silently falls through to a no-op and still exits 0, so a caller trusting # the exit code alone believes a comment was posted when it was not (#865). # Because that failure mode is silent, this script never trusts tea's exit # code alone: after posting, it independently re-fetches the issue's comments # via the Gitea REST API (curl — urllib is blocked by Cloudflare on this # host) and fails closed if the posted body cannot be found. # # --login override: the default `--login` is resolved from the local `tea` # login list for this repo's host (get_gitea_login). Pass --login to # override that default for this invocation only. The override is appended # to the tea command line AFTER the detected default, because tea honors # only the LAST `--login` flag on the command line — a flag placed before # the default would be silently clobbered by it. set -e SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/detect-platform.sh" # Parse arguments ISSUE_NUMBER="" COMMENT="" LOGIN_OVERRIDE="" while [[ $# -gt 0 ]]; do case $1 in -i|--issue) ISSUE_NUMBER="$2" shift 2 ;; -c|--comment) COMMENT="$2" shift 2 ;; -l|--login) LOGIN_OVERRIDE="$2" shift 2 ;; -h|--help) echo "Usage: issue-comment.sh -i -c [--login ]" echo "" echo "Options:" echo " -i, --issue Issue number (required)" echo " -c, --comment Comment text (required)" echo " -l, --login Override the detected Gitea tea login for this call" echo " -h, --help Show this help" exit 0 ;; *) echo "Unknown option: $1" exit 1 ;; esac done if [[ -z "$ISSUE_NUMBER" ]]; then echo "Error: Issue number is required (-i)" exit 1 fi if [[ -z "$COMMENT" ]]; then echo "Error: Comment is required (-c)" exit 1 fi detect_platform >/dev/null # Resolve and cache the Gitea REST endpoint + token for the current remote. # Populates GITEA_API_ROOT (…/api/v1), GITEA_API_BASE (…/api/v1/repos/), # and GITEA_API_TOKEN. Returns non-zero (with a clear stderr message) if any # part of the resolution fails. gitea_resolve_api() { local host configured_url repo host=$(get_remote_host) GITEA_API_TOKEN=$(get_gitea_token "$host") || { echo "Error: Gitea token not found for comment read-back verification" >&2 return 1 } configured_url=$(get_gitea_url_for_host "$host") || { echo "Error: Configured Gitea URL not found for comment read-back verification" >&2 return 1 } repo=$(get_gitea_repo_slug_for_url "$configured_url") || { echo "Error: Could not resolve Gitea owner/repository relative to configured URL" >&2 return 1 } GITEA_API_ROOT="${configured_url%/}/api/v1" GITEA_API_BASE="$GITEA_API_ROOT/repos/$repo" return 0 } # Fetch every page of a Gitea list endpoint into $2 (merged into one JSON # array). Gitea paginates list responses, so a single-page read would # false-negative once a newly created record lands beyond page 1. Walks # page=1,2,… until a short page (fewer than the requested limit) or an empty # page is returned, so the merged array is exhaustive. $1 is the endpoint URL # with NO query string. Returns non-zero (clear stderr) on any transport / # HTTP / parse failure. gitea_fetch_all() { local base_url="$1" dest="$2" page=1 limit=50 status page_file count printf '[]' > "$dest" while :; do page_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-issue-comment-page.XXXXXX") if ! status=$(curl -sS -o "$page_file" -w '%{http_code}' \ -H "Authorization: token $GITEA_API_TOKEN" \ "${base_url}?limit=${limit}&page=${page}"); then rm -f "$page_file" echo "Error: Gitea list read transport failed" >&2 return 1 fi if [[ "$status" != "200" ]]; then rm -f "$page_file" echo "Error: Gitea list read failed with HTTP $status" >&2 return 1 fi count=$(DEST="$dest" python3 - "$page_file" <<'PY' import json import os import sys try: with open(os.environ["DEST"], encoding="utf-8") as merged_file: merged = json.load(merged_file) with open(sys.argv[1], encoding="utf-8") as page_file: page = json.load(page_file) if not isinstance(page, list): raise ValueError("page response is not a list") merged.extend(item for item in page if isinstance(item, dict)) with open(os.environ["DEST"], "w", encoding="utf-8") as merged_file: json.dump(merged, merged_file) except (OSError, json.JSONDecodeError, TypeError, ValueError) as error: print(f"Error: could not merge Gitea list page: {error}", file=sys.stderr) raise SystemExit(1) print(len(page)) PY ) || { rm -f "$page_file"; return 1; } rm -f "$page_file" [[ "$count" -lt "$limit" ]] && break page=$((page + 1)) if [[ "$page" -gt 1000 ]]; then echo "Error: Gitea list pagination exceeded 1000 pages" >&2 return 1 fi done return 0 } # Resolve the login of the identity the API token authenticates as (GET # /user). Used to attribute a read-back record to THIS invocation's writer so # a concurrent write from a DIFFERENT identity cannot satisfy verification. # Prints the login on success. gitea_authenticated_login() { local response_file status response_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-issue-comment-whoami.XXXXXX") trap 'rm -f "$response_file"' RETURN if ! status=$(curl -sS -o "$response_file" -w '%{http_code}' \ -H "Authorization: token $GITEA_API_TOKEN" \ "$GITEA_API_ROOT/user"); then echo "Error: Gitea authenticated-identity read transport failed" >&2 return 1 fi if [[ "$status" != "200" ]]; then echo "Error: Gitea authenticated-identity read failed with HTTP $status" >&2 return 1 fi python3 - "$response_file" <<'PY' import json import sys try: with open(sys.argv[1], encoding="utf-8") as response: user = json.load(response) login = user.get("login") if isinstance(user, dict) else None if not isinstance(login, str) or not login: raise ValueError("missing authenticated login") except (OSError, json.JSONDecodeError, TypeError, ValueError) as error: print(f"Error: could not resolve authenticated Gitea identity: {error}", file=sys.stderr) raise SystemExit(1) print(login) PY } # Print the maximum existing comment id on an issue (0 if none). This is the # pre-write BOUNDARY: Gitea comment ids are monotonic, so any comment created # by a subsequent write has an id strictly greater than this value. gitea_max_comment_id() { local issue_number="$1" merged_file merged_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-issue-comment-boundary.XXXXXX") trap 'rm -f "$merged_file"' RETURN gitea_fetch_all "$GITEA_API_BASE/issues/$issue_number/comments" "$merged_file" || return 1 python3 - "$merged_file" <<'PY' import json import sys try: with open(sys.argv[1], encoding="utf-8") as response: comments = json.load(response) ids = [c.get("id") for c in comments if isinstance(c, dict) and isinstance(c.get("id"), int)] print(max(ids) if ids else 0) except (OSError, json.JSONDecodeError, TypeError, ValueError) as error: print(f"Error: could not compute Gitea comment boundary: {error}", file=sys.stderr) raise SystemExit(1) PY } # Independently re-fetch (all pages of) the issue's comments and require a # comment attributable to THIS invocation: id strictly greater than the # pre-write boundary AND author login equal to the acting identity AND exact # body match. tea's exit code is not trustworthy evidence of a durable write # on its own (#865); id-above-boundary alone is only temporal ordering, so the # author-login check is what excludes a concurrent write by a DIFFERENT # identity. Prints the matched comment ID on success. # # Residual (documented, not eliminable without a tea-emitted created-record # id, which tea 0.11.1 does not reliably provide): a concurrent write by the # SAME identity with an identical body inside the boundary window could still # be accepted. That is a strictly narrower window than temporal-only matching. gitea_verify_comment_posted() { local issue_number="$1" comment_body="$2" boundary="$3" acting_login="$4" local merged_file merged_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-issue-comment-readback.XXXXXX") trap 'rm -f "$merged_file"' RETURN gitea_fetch_all "$GITEA_API_BASE/issues/$issue_number/comments" "$merged_file" || return 1 EXPECTED_COMMENT_BODY="$comment_body" BOUNDARY_COMMENT_ID="$boundary" ACTING_LOGIN="$acting_login" \ python3 - "$merged_file" <<'PY' import json import os import sys try: with open(sys.argv[1], encoding="utf-8") as response: comments = json.load(response) if not isinstance(comments, list): raise ValueError("response is not a comment list") expected_body = os.environ["EXPECTED_COMMENT_BODY"] boundary = int(os.environ["BOUNDARY_COMMENT_ID"]) acting_login = os.environ["ACTING_LOGIN"] # Attribution to THIS write: created-after-boundary AND authored by the # acting identity AND exact body match. The author check excludes a # concurrent DIFFERENT-identity writer that id+body alone would admit. matches = [ c for c in comments if isinstance(c, dict) and isinstance(c.get("id"), int) and c.get("id") > boundary and (c.get("user") or {}).get("login") == acting_login and c.get("body") == expected_body ] if not matches: raise ValueError( "no comment attributable to this write matched " "(id > boundary, acting identity, exact body); " "tea may have silently no-opped (#865)" ) comment_id = max(c["id"] for c in matches) except (OSError, json.JSONDecodeError, KeyError, TypeError, ValueError) as error: print(f"Error: Gitea comment persistence verification failed: {error}", file=sys.stderr) raise SystemExit(1) print(comment_id) PY } if [[ "$PLATFORM" == "github" ]]; then gh issue comment "$ISSUE_NUMBER" --body "$COMMENT" echo "Added comment to GitHub issue #$ISSUE_NUMBER" elif [[ "$PLATFORM" == "gitea" ]]; then # Build the invocation as an argv array (not unquoted $(get_gitea_repo_args) # word-splitting) so the comment body — including Markdown backticks, $(...), # and quotes — is passed verbatim and never re-split or shell-evaluated. REPO_SLUG=$(get_repo_slug) GITEA_LOGIN_NAME=$(get_gitea_login) || { echo "Error: could not resolve a Gitea login for this repo; cannot comment on issue #$ISSUE_NUMBER." >&2 exit 1 } # Resolve the REST endpoint, the acting identity, and the pre-write # boundary BEFORE the write, so the read-back can require a strictly-newer # comment id authored by this identity. gitea_resolve_api || exit 1 ACTING_LOGIN=$(gitea_authenticated_login) || exit 1 boundary=$(gitea_max_comment_id "$ISSUE_NUMBER") || exit 1 TEA_ARGS=(comment "$ISSUE_NUMBER" "$COMMENT" --repo "$REPO_SLUG" --login "$GITEA_LOGIN_NAME") # --login override goes LAST: tea honors only the final --login on its # command line, so an override placed before the detected default above # would be silently clobbered by it. if [[ -n "$LOGIN_OVERRIDE" ]]; then TEA_ARGS+=(--login "$LOGIN_OVERRIDE") fi tea "${TEA_ARGS[@]}" comment_id=$(gitea_verify_comment_posted "$ISSUE_NUMBER" "$COMMENT" "$boundary" "$ACTING_LOGIN") || { echo "Error: could not verify comment landed on Gitea issue #$ISSUE_NUMBER via bounded read-back; treating tea's exit code as untrustworthy (#865)." >&2 exit 1 } echo "Added and verified comment on Gitea issue #$ISSUE_NUMBER (comment ID $comment_id)" else echo "Error: Unknown platform" exit 1 fi