ci/woodpecker/pr/ci Pipeline failed
Upstream of brain commits 08a00149 + 971586ef + the arm-6 sandbox fix (brain 5th commit of 2026-08-28 series): - R1: -b/--body is the canonical comment flag (matches issue-create, issue-edit, pr-create, pr-edit); -c/--comment stays a compatible alias. - R4: usage errors print to stderr and exit 2, distinct from provider, credential, and verification failures (exit 1). Value-less flags fail loudly (previously -c with no value died silently at rc=1 via set -e on the failed shift 2). gh failures on the GitHub path normalize to exit 1 (gh's own usage errors exit 2 and would collide with the reserved code). - Tests: new usage-contract suite (help rc, unknown/missing/value-less rc=2 on stderr, alias parse acceptance under a sandboxed runner, GitHub-path exit normalization with a stubbed gh, zero provider contact on parser failure); readback suite gains case 11 (full verified write via -b) and neutralizes seat-exported MOSAIC_GIT_IDENTITY / MOSAIC_BRAIN_HOME that escape the sandboxed HOME (documented HTTP 401 / fail-loud shapes). Driver: a fleet seat full-stopped on an issue-comment usage error because usage failures were indistinguishable from provider failures and the stop gate treated every wrapper failure as blocking.
396 lines
18 KiB
Bash
Executable File
396 lines
18 KiB
Bash
Executable File
#!/bin/bash
|
|
# issue-comment.sh - Add a comment to an issue on GitHub or Gitea
|
|
# Usage: issue-comment.sh -i <issue_number> -b <comment> [--login <name>]
|
|
# (-c/--comment is a backward-compatible alias for -b/--body; R1, 2026-08-28)
|
|
#
|
|
# tea v0.11.1 defines no `comment` subcommand under `tea issue` (or `tea pr`);
|
|
# the non-existent `tea issue comment ...` form does not error — tea silently
|
|
# no-ops and still exits 0, so a caller trusting the exit code believes a
|
|
# comment was posted when it was not (#865). tea 0.11.1 also cannot reliably
|
|
# emit the id of a record it created, so an exit code is the ONLY signal it
|
|
# offers — and that signal is untrustworthy. This script therefore does not
|
|
# write via tea at all: it POSTs the comment through the Gitea REST API (which
|
|
# returns the created comment object, including its id), then GETs that exact
|
|
# id back and fails closed unless it matches. Keying verification to the
|
|
# provider-returned created id means a concurrent comment cannot masquerade as
|
|
# this write and a no-op create simply yields no id to verify.
|
|
#
|
|
# --login override: the default login is resolved from the local `tea` login
|
|
# list for this repo's host (get_gitea_login). Pass --login <name> to override
|
|
# it for this invocation only. The REST write, the /user identity read, and the
|
|
# read-back are ALL performed with the token of the EFFECTIVE login (the
|
|
# override when given), so the write and its verification bind to the same
|
|
# identity — a --login override is never written under one credential and
|
|
# verified under a different default one.
|
|
|
|
set -e
|
|
|
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
source "$SCRIPT_DIR/detect-platform.sh"
|
|
|
|
# Parse arguments
|
|
ISSUE_NUMBER=""
|
|
COMMENT=""
|
|
LOGIN_OVERRIDE=""
|
|
|
|
# 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
|
|
# (CONSTITUTION gate 8 as amended; E2E-DELIVERY).
|
|
usage_error() {
|
|
echo "Error: $*" >&2
|
|
echo "Usage: issue-comment.sh -i <issue_number> -b <comment> [--login <name>] (see --help)" >&2
|
|
exit 2
|
|
}
|
|
|
|
while [[ $# -gt 0 ]]; do
|
|
case $1 in
|
|
-i|--issue)
|
|
[[ $# -ge 2 ]] || usage_error "option $1 requires a value"
|
|
ISSUE_NUMBER="$2"
|
|
shift 2
|
|
;;
|
|
-b|--body|-c|--comment)
|
|
# R1 (2026-08-28): --body is the canonical flag, matching
|
|
# issue-create/issue-edit/pr-create/pr-edit; -c/--comment stays a
|
|
# backward-compatible alias.
|
|
[[ $# -ge 2 ]] || usage_error "option $1 requires a value"
|
|
COMMENT="$2"
|
|
shift 2
|
|
;;
|
|
-l|--login)
|
|
[[ $# -ge 2 ]] || usage_error "option $1 requires a value"
|
|
LOGIN_OVERRIDE="$2"
|
|
shift 2
|
|
;;
|
|
-h|--help)
|
|
echo "Usage: issue-comment.sh -i <issue_number> -b <comment> [--login <name>]"
|
|
echo ""
|
|
echo "Options:"
|
|
echo " -i, --issue Issue number (required)"
|
|
echo " -b, --body Comment text (required; canonical)"
|
|
echo " -c, --comment Alias for --body"
|
|
echo " -l, --login Override the detected Gitea tea login for this call"
|
|
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
|
|
|
|
if [[ -z "$ISSUE_NUMBER" ]]; then
|
|
usage_error "issue number is required (-i/--issue)"
|
|
fi
|
|
|
|
if [[ -z "$COMMENT" ]]; then
|
|
usage_error "comment is required (-b/--body, or the -c/--comment alias)"
|
|
fi
|
|
|
|
detect_platform >/dev/null
|
|
|
|
# Resolve and cache the Gitea REST endpoint + token for the current remote,
|
|
# bound to a SPECIFIC login identity ($1). Populates GITEA_API_ROOT (…/api/v1),
|
|
# GITEA_API_BASE (…/api/v1/repos/<slug>), and GITEA_API_TOKEN.
|
|
#
|
|
# The token is resolved for the EFFECTIVE login (the --login override when
|
|
# given, otherwise the detected default) so that the single credential used for
|
|
# the write ALSO drives the /user identity read and the read-back — write token
|
|
# and read-back token are the same identity by construction (this is the
|
|
# credential-ordering fix: a --login override is no longer written under one
|
|
# credential and verified under a different default one). Falls back to the
|
|
# host-scoped credential ONLY when NO --login override was supplied (the
|
|
# best-effort default path). When $2 is "explicit" the login came from a
|
|
# caller-supplied --login: that exact login's token MUST resolve, and we FAIL
|
|
# CLOSED rather than silently downgrading the write to the host default
|
|
# identity — otherwise a caller relying on a dedicated per-role credential would
|
|
# be told the write succeeded as requested while it was attributed to the shared
|
|
# default. Returns non-zero (clear stderr) on any resolution failure.
|
|
gitea_resolve_api_for_login() {
|
|
local effective_login="$1" override_explicit="${2:-}" host configured_url repo
|
|
|
|
host=$(get_remote_host)
|
|
if [[ -n "$override_explicit" ]]; then
|
|
GITEA_API_TOKEN=$(get_gitea_token_for_login "$effective_login" "$host") || {
|
|
echo "Error: could not resolve a host-matched Gitea token for --login '$effective_login' on host '$host'; refusing to fall back to the host default identity or a cross-host credential (comment write/read-back)" >&2
|
|
return 1
|
|
}
|
|
else
|
|
# NO --login: the acting credential is this identity's own token and there
|
|
# is deliberately no tea-config fallback. get_gitea_token_for_login matches
|
|
# by login NAME and performs no authentication check, and with no --login
|
|
# that name was a HOST GUESS resolving to a shared account. A live shared
|
|
# token would therefore have authored every seat's comment as that
|
|
# account, making Gate-16 author-is-not-reviewer unenforceable fleet-wide;
|
|
# a dead one is only what made the defect visible. get_gitea_token fails
|
|
# loud on a fleet host when no identity resolves, and that refusal is the
|
|
# correct outcome, not a case to fall back from.
|
|
GITEA_API_TOKEN=$(get_gitea_token "$host") || {
|
|
echo "Error: no Gitea credential resolved for the acting identity on host '$host' (comment write/read-back). Set MOSAIC_GIT_IDENTITY=<agent-id>, or pass --login <name> to use a named tea credential." >&2
|
|
return 1
|
|
}
|
|
fi
|
|
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"
|
|
# The provider WEB base (scheme + host + effective port + any deployment path
|
|
# prefix) that Gitea uses to build a comment's html issue_url/pull_request_url.
|
|
# Read-back verification pins the returned URL's origin + path prefix to THIS,
|
|
# not just a repo/issue suffix.
|
|
GITEA_WEB_BASE="${configured_url%/}"
|
|
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 auth_config status
|
|
|
|
response_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-issue-comment-whoami.XXXXXX")
|
|
auth_config=$(gitea_write_auth_config "$GITEA_API_TOKEN") || {
|
|
rm -f "$response_file"
|
|
echo "Error: could not stage Gitea credential for identity read" >&2
|
|
return 1
|
|
}
|
|
trap 'rm -f "$response_file" "$auth_config"' RETURN
|
|
|
|
if ! status=$(curl -sS -o "$response_file" -w '%{http_code}' \
|
|
--config "$auth_config" \
|
|
"$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
|
|
}
|
|
|
|
# Post a comment to a Gitea issue via the supported REST API and verify it
|
|
# durably against a PROVIDER-RETURNED created id — never trust an exit code
|
|
# (#865 defect class: tea's non-existent `tea issue comment` no-ops yet exits
|
|
# 0). The write is a direct POST that returns the created comment object, so we
|
|
# learn the exact id of THIS write; we then GET that exact id and require
|
|
# id == created id AND author == acting identity AND exact body AND that it
|
|
# belongs to this issue. Because verification is keyed to the id the create
|
|
# returned, a concurrent comment (even same identity, same body) CANNOT
|
|
# masquerade as this write, and a suppressed/no-op write yields no created id
|
|
# and fails closed — there is no fallback list scan that a concurrent record
|
|
# could satisfy. Prints the created comment id on success.
|
|
#
|
|
# Args: $1 = issue number, $2 = comment body, $3 = acting identity login.
|
|
gitea_create_comment_verified() {
|
|
local issue_number="$1" comment_body="$2" acting_login="$3"
|
|
local payload write_file readback_file auth_config write_status readback_status created_id
|
|
|
|
payload=$(COMMENT_BODY="$comment_body" python3 -c '
|
|
import json
|
|
import os
|
|
|
|
print(json.dumps({"body": os.environ["COMMENT_BODY"]}))
|
|
')
|
|
write_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-issue-comment-write.XXXXXX")
|
|
readback_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-issue-comment-getid.XXXXXX")
|
|
auth_config=$(gitea_write_auth_config "$GITEA_API_TOKEN") || {
|
|
rm -f "$write_file" "$readback_file"
|
|
echo "Error: could not stage Gitea credential for comment write" >&2
|
|
return 1
|
|
}
|
|
trap 'rm -f "$write_file" "$readback_file" "$auth_config"' RETURN
|
|
|
|
if ! write_status=$(curl -sS -o "$write_file" -w '%{http_code}' \
|
|
-X POST \
|
|
--config "$auth_config" \
|
|
-H 'Content-Type: application/json' \
|
|
-d "$payload" \
|
|
"$GITEA_API_BASE/issues/$issue_number/comments"); then
|
|
echo "Error: Gitea comment write transport failed" >&2
|
|
return 1
|
|
fi
|
|
if [[ "$write_status" != "201" ]]; then
|
|
echo "Error: Gitea comment write failed with HTTP $write_status (#865: no durable comment created)" >&2
|
|
return 1
|
|
fi
|
|
|
|
created_id=$(python3 - "$write_file" <<'PY'
|
|
import json
|
|
import sys
|
|
|
|
try:
|
|
with open(sys.argv[1], encoding="utf-8") as response:
|
|
comment = json.load(response)
|
|
created_id = comment.get("id") if isinstance(comment, dict) else None
|
|
if not isinstance(created_id, int) or created_id <= 0:
|
|
raise ValueError("create response carried no positive comment id")
|
|
except (OSError, json.JSONDecodeError, ValueError) as error:
|
|
print(f"Error: could not identify created Gitea comment: {error}", file=sys.stderr)
|
|
raise SystemExit(1)
|
|
print(created_id)
|
|
PY
|
|
) || return 1
|
|
|
|
if ! readback_status=$(curl -sS -o "$readback_file" -w '%{http_code}' \
|
|
--config "$auth_config" \
|
|
"$GITEA_API_BASE/issues/comments/$created_id"); then
|
|
echo "Error: Gitea comment read-back transport failed" >&2
|
|
return 1
|
|
fi
|
|
if [[ "$readback_status" != "200" ]]; then
|
|
echo "Error: Gitea comment read-back failed with HTTP $readback_status" >&2
|
|
return 1
|
|
fi
|
|
|
|
EXPECTED_COMMENT_ID="$created_id" EXPECTED_COMMENT_BODY="$comment_body" \
|
|
ACTING_LOGIN="$acting_login" EXPECTED_REPO_SLUG="${GITEA_API_BASE##*/repos/}" \
|
|
EXPECTED_NUMBER="$issue_number" EXPECTED_WEB_BASE="$GITEA_WEB_BASE" \
|
|
python3 - "$readback_file" <<'PY' || return 1
|
|
import json
|
|
import os
|
|
import sys
|
|
from urllib.parse import urlparse
|
|
|
|
|
|
def _origin_and_path(url):
|
|
# Normalize a URL to (scheme-class, host, distinguishing-port) + comment path.
|
|
#
|
|
# #991: http and https collapse into ONE scheme class ("web"). A Gitea whose
|
|
# ROOT_URL is configured http:// returns http:// object URLs even when every
|
|
# client reaches it over https://, so a scheme-strict comparison rejects the
|
|
# provider's own correct answer about a write that landed — a deterministic
|
|
# false negative on every comment posted against such a deployment. The
|
|
# scheme is also not what this check defends: the forgeries it exists to
|
|
# catch (look-alike host, decoy path prefix, wrong owner/repo/number) all
|
|
# vary the HOST or the PATH, both of which stay strict below. Any OTHER
|
|
# scheme (file:, ftp:, javascript:) remains distinguishing and is rejected.
|
|
#
|
|
# Port: an implicit port and its own scheme's default compare equal, so
|
|
# http://h == https://h. An EXPLICIT non-default port still distinguishes,
|
|
# because a different port is a different service on the same host.
|
|
parsed = urlparse(url or "")
|
|
scheme = (parsed.scheme or "").lower()
|
|
host = (parsed.hostname or "").lower()
|
|
if scheme in ("http", "https"):
|
|
scheme_class = "web"
|
|
default_port = 80 if scheme == "http" else 443
|
|
port = None if parsed.port in (None, default_port) else parsed.port
|
|
else:
|
|
scheme_class = scheme
|
|
port = parsed.port
|
|
return (scheme_class, host, port), parsed.path.rstrip("/")
|
|
|
|
|
|
try:
|
|
with open(sys.argv[1], encoding="utf-8") as response:
|
|
comment = json.load(response)
|
|
if not isinstance(comment, dict):
|
|
raise ValueError("response is not a comment object")
|
|
expected_id = int(os.environ["EXPECTED_COMMENT_ID"])
|
|
expected_body = os.environ["EXPECTED_COMMENT_BODY"]
|
|
acting_login = os.environ["ACTING_LOGIN"]
|
|
slug = os.environ["EXPECTED_REPO_SLUG"]
|
|
number = os.environ["EXPECTED_NUMBER"]
|
|
web_base = os.environ["EXPECTED_WEB_BASE"]
|
|
# Gitea populates WEB (html) URLs here, not API paths. A plain issue comment
|
|
# carries issue_url = <web_base>/<owner>/<repo>/issues/<n> (pull_request_url
|
|
# empty); a comment posted to a PR's conversation carries
|
|
# pull_request_url = <web_base>/<owner>/<repo>/pulls/<n> (issue_url empty).
|
|
# Pin the returned URL's ORIGIN (scheme+host+port) and its FULL path to this
|
|
# provider + repo + kind + number — an endswith/suffix test would accept a
|
|
# look-alike host (evil.example/deceptive/<slug>/issues/N) or a same-host
|
|
# decoy prefix (/other/<slug>/issues/N), so compare the whole thing.
|
|
base_origin, base_path = _origin_and_path(web_base)
|
|
expected_issue_path = f"{base_path}/{slug}/issues/{number}"
|
|
expected_pr_path = f"{base_path}/{slug}/pulls/{number}"
|
|
|
|
def _belongs(url, expected_path):
|
|
if not url:
|
|
return False
|
|
origin, path = _origin_and_path(url)
|
|
return origin == base_origin and path == expected_path
|
|
|
|
if comment.get("id") != expected_id:
|
|
raise ValueError("read-back id does not match the created id")
|
|
if (comment.get("user") or {}).get("login") != acting_login:
|
|
raise ValueError("created comment is not authored by the acting identity")
|
|
if comment.get("body") != expected_body:
|
|
raise ValueError("created comment body does not match")
|
|
if not (
|
|
_belongs(comment.get("issue_url"), expected_issue_path)
|
|
or _belongs(comment.get("pull_request_url"), expected_pr_path)
|
|
):
|
|
raise ValueError("created comment does not belong to this issue on this provider/repo")
|
|
except (OSError, json.JSONDecodeError, KeyError, TypeError, ValueError) as error:
|
|
print(f"Error: Gitea comment persistence verification failed: {error}", file=sys.stderr)
|
|
raise SystemExit(1)
|
|
PY
|
|
|
|
echo "$created_id"
|
|
return 0
|
|
}
|
|
|
|
if [[ "$PLATFORM" == "github" ]]; then
|
|
# R4 exit-code contract: normalize provider failures to exit 1. gh's own
|
|
# usage errors exit 2, which would collide with this wrapper's reserved
|
|
# usage-error status if propagated raw (codex review of 08a00149).
|
|
gh_rc=0
|
|
gh issue comment "$ISSUE_NUMBER" --body "$COMMENT" || gh_rc=$?
|
|
if [[ "$gh_rc" -ne 0 ]]; then
|
|
echo "Error: GitHub comment write failed (gh exit $gh_rc; provider/credential failure — usage errors are exit 2)" >&2
|
|
exit 1
|
|
fi
|
|
echo "Added comment to GitHub issue #$ISSUE_NUMBER"
|
|
elif [[ "$PLATFORM" == "gitea" ]]; then
|
|
# A --login override selects a NAMED tea credential and is the only way to
|
|
# reach the tea store. With no --login there is deliberately no guess: the
|
|
# comment is attributed to this identity's own credential, resolved by
|
|
# gitea_resolve_api_for_login. The guess this replaced named a SHARED
|
|
# account, selecting an identity the caller never asked to act as.
|
|
EFFECTIVE_LOGIN="$LOGIN_OVERRIDE"
|
|
|
|
# Bind the REST endpoint + token to the effective login, then derive the
|
|
# acting identity from that SAME credential (GET /user). The write below and
|
|
# its read-back both use this credential, so the write is verified against
|
|
# the identity that actually performed it. Passing "explicit" when --login
|
|
# was supplied forbids the host-default fallback: an unresolvable explicit
|
|
# override fails closed instead of writing under the default identity.
|
|
gitea_resolve_api_for_login "$EFFECTIVE_LOGIN" "${LOGIN_OVERRIDE:+explicit}" || exit 1
|
|
ACTING_LOGIN=$(gitea_authenticated_login) || exit 1
|
|
|
|
comment_id=$(gitea_create_comment_verified "$ISSUE_NUMBER" "$COMMENT" "$ACTING_LOGIN") || {
|
|
echo "Error: could not create and verify a comment on Gitea issue #$ISSUE_NUMBER via a provider-returned created id (#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
|