#!/bin/bash # issue-create.sh - Create issues on Gitea or GitHub # Usage: issue-create.sh -t "Title" [-b "Body"] [-l "label1,label2"] [-m "milestone"] [--login ] # # Acting principal is resolved identity-first (#1280): an explicit --login # wins; otherwise MOSAIC_GIT_IDENTITY / per-worktree git config # mosaic.gitIdentity selects the principal when a per-slot token exists (and # the wrapper then creates the issue through the REST API with that identity's # token — tea is never invoked, so the tea login list cannot shadow the # requested principal); the tea login list is the LAST resort. A requested # identity with no per-slot token fails LOUD rather than writing under # whichever account tea happens to hold. set -e SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/detect-platform.sh" # Default values TITLE="" BODY="" LABELS="" MILESTONE="" INTERACTIVE=false # get_remote_host and get_gitea_token are provided by detect-platform.sh # Acting-principal mode set in the Gitea branch below (from # resolve_gitea_principal): "login" when --login was given, "identity" when a # git identity bound, "default" otherwise. PRINCIPAL_MODE=login makes the API # arm resolve the --login principal's token too, so an explicit --login keeps # winning even on the tea-FAILURE fallback arm. PRINCIPAL_MODE="" PRINCIPAL_NAME="" gitea_issue_create_api() { local host repo token url payload host=$(get_remote_host) || { echo "Error: could not determine remote host for API fallback" >&2 return 1 } repo=$(get_repo_info) || { echo "Error: could not determine repo owner/name for API fallback" >&2 return 1 } if [[ "$PRINCIPAL_MODE" == "login" ]]; then token=$(get_gitea_token_for_login "$PRINCIPAL_NAME" "$host") || { echo "Error: could not resolve a host-matched Gitea token for --login '$PRINCIPAL_NAME' on host '$host' (API path)" >&2 return 1 } else # Identity-first when MOSAIC_GIT_IDENTITY / git config mosaic.gitIdentity # is set (per-slot token, fail-loud on absence); shared default otherwise. token=$(get_gitea_token "$host") || { echo "Error: Gitea token not found for API fallback (set GITEA_TOKEN or configure ~/.git-credentials)" >&2 return 1 } fi if [[ -n "$LABELS" || -n "$MILESTONE" ]]; then echo "Warning: API fallback currently applies title/body only; labels/milestone require authenticated tea setup." >&2 fi payload=$(TITLE="$TITLE" BODY="$BODY" python3 - <<'PY' import json import os payload = {"title": os.environ["TITLE"]} body = os.environ.get("BODY", "") if body: payload["body"] = body print(json.dumps(payload)) PY ) url="https://${host}/api/v1/repos/${repo}/issues" curl -fsS -X POST \ -H "User-Agent: curl/8" \ -H "Authorization: token ${token}" \ -H "Content-Type: application/json" \ -d "$payload" \ "$url" } usage() { cat <&2 usage ;; esac done if [[ "$INTERACTIVE" == true ]]; then [[ -n "$TITLE" ]] || read -r -p "Issue title: " TITLE [[ -n "$BODY" ]] || read -r -p "Issue body (optional): " BODY || true [[ -n "$LABELS" ]] || read -r -p "Labels, comma-separated (optional): " LABELS || true [[ -n "$MILESTONE" ]] || read -r -p "Milestone (optional): " MILESTONE || true fi if [[ -z "$TITLE" ]]; then echo "Error: Title is required (-t)" >&2 usage fi PLATFORM=$(detect_platform) case "$PLATFORM" in github) CMD=(gh issue create --title "$TITLE") [[ -n "$BODY" ]] && CMD+=(--body "$BODY") [[ -n "$LABELS" ]] && CMD+=(--label "$LABELS") [[ -n "$MILESTONE" ]] && CMD+=(--milestone "$MILESTONE") "${CMD[@]}" ;; gitea) # Resolve the acting principal identity-first (#1280). The tea login # list is the LAST resort: it knows nothing about which seat is calling, # and a login resolved from it first is what attributed issues to the # wrong account even when MOSAIC_GIT_IDENTITY was set. principal_host=$(get_remote_host 2>/dev/null || true) if ! principal_resolved="$(resolve_gitea_principal "${LOGIN_OVERRIDE:-}" "$principal_host")"; then # resolve_gitea_principal already printed the fail-loud diagnostic. exit 1 fi PRINCIPAL_MODE="$(printf '%s' "$principal_resolved" | cut -f1)" PRINCIPAL_NAME="$(printf '%s' "$principal_resolved" | cut -f2)" if [[ "$PRINCIPAL_MODE" == "identity" ]]; then # HAPPY PATH for a requested identity: create through the REST API # with the per-slot token and never invoke tea — the identity arm # must be REACHED, not sit behind a tea failure (#1280). gitea_issue_create_api exit $? fi if command -v tea >/dev/null 2>&1; then REPO_SLUG=$(get_repo_slug) if [[ "$PRINCIPAL_MODE" == "login" ]]; then GITEA_LOGIN_NAME="$PRINCIPAL_NAME" else GITEA_LOGIN_NAME=$(get_gitea_login) || { echo "Warning: could not resolve Gitea login for tea; trying Gitea API fallback..." >&2 gitea_issue_create_api exit $? } fi if ! get_gitea_authenticated_user "$GITEA_LOGIN_NAME" >/dev/null; then echo "Warning: Tea authenticated-user validation failed (possible stale user/login); trying Gitea API fallback..." >&2 gitea_issue_create_api exit $? fi REPO_ARGS=(--repo "$REPO_SLUG" --login "$GITEA_LOGIN_NAME") CMD=(tea issue create "${REPO_ARGS[@]}" --title "$TITLE") [[ -n "$BODY" ]] && CMD+=(--description "$BODY") [[ -n "$LABELS" ]] && CMD+=(--labels "$LABELS") # tea accepts milestone by name directly (verified 2026-02-05) [[ -n "$MILESTONE" ]] && CMD+=(--milestone "$MILESTONE") if "${CMD[@]}"; then exit 0 fi echo "Warning: tea issue create failed, trying Gitea API fallback..." >&2 fi gitea_issue_create_api ;; *) echo "Error: Could not detect git platform" >&2 exit 1 ;; esac