#!/bin/bash # issue-close.sh - Close an issue on GitHub or Gitea # Usage: issue-close.sh -i [-b ] # (-c/--comment is a backward-compatible alias for -b/--body; R1/R4 2026-08-28) set -e # Source platform detection SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/detect-platform.sh" # Parse arguments ISSUE_NUMBER="" COMMENT="" BODY_FILE="" # Usage-error contract (R4, 2026-08-28): usage errors print to STDERR and exit 2, # distinct from provider, credential, and verification failures (exit 1), so a # caller or stop gate can tell an invocation defect from a delivery blocker. usage_error() { echo "Error: $*" >&2 echo "Usage: issue-close.sh -i [-b ] (see --help)" >&2 exit 2 } while [[ $# -gt 0 ]]; do case $1 in -i|--issue) [[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)" ISSUE_NUMBER="$2" shift 2 ;; -b|--body|-c|--comment) # R1 (2026-08-28): --body is the canonical flag; -c/--comment stays # a backward-compatible alias. [[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)" COMMENT="$2" shift 2 ;; --body-file) # R3: body from file (or '-' = stdin); mutually exclusive with --body. [[ $# -ge 2 && "$2" != --* ]] || usage_error "option $1 requires a path (or - for stdin)" BODY_FILE="$2" shift 2 ;; -h|--help) echo "Usage: issue-close.sh -i [-b ]" echo "" echo "Options:" echo " -i, --issue Issue number (required)" echo " -b, --body Comment to add before closing (optional; canonical)" echo " -c, --comment Alias for --body" echo " -h, --help Show this help" echo "" echo "Exit codes: 0 success; 2 usage error (stderr); 1 provider/credential/verification failure." exit 0 ;; *) usage_error "unknown option: $1" ;; esac done # R3 (2026-08-29): resolve --body-file into COMMENT (file or stdin '-'); # exclusive with an explicit --body/--comment value. if [[ -n "$BODY_FILE" ]]; then [[ -z "$COMMENT" ]] || usage_error "--body-file and --body are mutually exclusive" if [[ "$BODY_FILE" == "-" ]]; then COMMENT=$(cat) || usage_error "could not read body from stdin" else [[ -r "$BODY_FILE" ]] || usage_error "body file not readable: $BODY_FILE" COMMENT=$(cat "$BODY_FILE") || usage_error "could not read body file: $BODY_FILE" fi fi if [[ -z "$ISSUE_NUMBER" ]]; then usage_error "issue number is required (-i/--issue)" fi # Detect platform and close issue detect_platform >/dev/null OWNER=$(get_repo_owner) REPO=$(get_repo_name) gitea_issue_comment_api() { local host token url payload host=$(get_remote_host) || return 1 token=$(get_gitea_token "$host") || return 1 url="https://${host}/api/v1/repos/${OWNER}/${REPO}/issues/${ISSUE_NUMBER}/comments" payload=$(COMMENT="$COMMENT" python3 - <<'PY' import json import os print(json.dumps({"body": os.environ["COMMENT"]})) PY ) curl -fsS -X POST \ -H "User-Agent: curl/8" \ -H "Authorization: token ${token}" \ -H "Content-Type: application/json" \ -d "$payload" \ "$url" >/dev/null } gitea_issue_close_api() { local host token url host=$(get_remote_host) || return 1 token=$(get_gitea_token "$host") || return 1 url="https://${host}/api/v1/repos/${OWNER}/${REPO}/issues/${ISSUE_NUMBER}" curl -fsS -X PATCH \ -H "User-Agent: curl/8" \ -H "Authorization: token ${token}" \ -H "Content-Type: application/json" \ -d '{"state":"closed"}' \ "$url" >/dev/null } if [[ "$PLATFORM" == "github" ]]; then # R4: normalize provider failures to exit 1 (gh's own usage errors exit 2 # and would collide with the reserved usage-error status). if [[ -n "$COMMENT" ]]; then gh_rc=0 gh issue comment "$ISSUE_NUMBER" --body "$COMMENT" || gh_rc=$? if [[ "$gh_rc" -ne 0 ]]; then echo "Error: GitHub comment before close failed (gh exit $gh_rc)" >&2 exit 1 fi fi gh_rc=0 gh issue close "$ISSUE_NUMBER" || gh_rc=$? if [[ "$gh_rc" -ne 0 ]]; then echo "Error: GitHub issue close failed (gh exit $gh_rc)" >&2 exit 1 fi echo "Closed GitHub issue #$ISSUE_NUMBER" elif [[ "$PLATFORM" == "gitea" ]]; then GITEA_LOGIN_NAME=$(get_gitea_login || true) if [[ -n "$GITEA_LOGIN_NAME" ]]; then if [[ -n "$COMMENT" ]]; then # `tea issue comment` is NOT a subcommand -- tea 0.11.x lists only # list/create/edit/reopen/close under `tea issue`. Comments are the # TOP-LEVEL `tea comment`, which takes the same --repo/--login flags. # The old call therefore always failed, was unchecked, and the script # closed the issue anyway, losing the record of WHY. # # Use `tea comment` rather than the API helper so the comment and the # close are made by the SAME principal ($GITEA_LOGIN_NAME). Routing the # comment through the token-authenticated helper here would attribute the # comment to the token holder and the close to the tea login -- two # principals for one operation. tea comment "$ISSUE_NUMBER" "$COMMENT" --repo "$OWNER/$REPO" --login "$GITEA_LOGIN_NAME" || { echo "Error: failed to post comment on #$ISSUE_NUMBER -- NOT closing (fail closed)." >&2 exit 1 } fi prov_rc=0 tea issue close "$ISSUE_NUMBER" --repo "$OWNER/$REPO" --login "$GITEA_LOGIN_NAME" || prov_rc=$? [[ "$prov_rc" -eq 0 ]] || { echo "Error: provider command failed (exit ${prov_rc}; provider failure, not a usage error)" >&2; exit 1; } else echo "No tea login configured for $(get_remote_host); using authenticated Gitea API fallback." >&2 if [[ -n "$COMMENT" ]]; then # Fail closed here too: an unchecked comment lets the issue close without its # audit trail, which is the same defect as the tea path above. gitea_issue_comment_api || { echo "Error: failed to post comment on #$ISSUE_NUMBER -- NOT closing (fail closed)." >&2 exit 1 } fi gitea_issue_close_api fi echo "Closed Gitea issue #$ISSUE_NUMBER" else echo "Error: Unknown platform" exit 1 fi