Compare commits

..

1 Commits

Author SHA1 Message Date
ms-856-build
1e6937c488 fix(framework): install deps on worktree bootstrap + legible deps-preflight at gate seam (#856)
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
Worker git-worktrees under a fresh `git worktree add` have empty
node_modules/.bin (pnpm workspaces do not share node_modules across
worktrees), so every gate binary (tsc/eslint/prettier/vitest) fails with
a raw `sh: 1: <tool>: not found` before the QA gate suite ever runs a
real check. That failure is indistinguishable from a genuine test/lint
failure -- a false-red gate that was responsible for 131/147 fleet
tool-errors.

- plugins/mosaic-framework/src/index.ts: the injected "Git Worktree
  Requirement" instructions now direct every worker to run
  `pnpm install --frozen-lockfile --prefer-offline` immediately after
  `git worktree add`/`cd`, before any gate is invoked.
- packages/mosaic/framework/tools/qa/qa-hook-stdin.sh: the common
  gate-entry seam (registered as the PostToolUse hook for every
  Edit/Write/MultiEdit in runtime/claude/settings.json) now preflights
  node_modules/.bin presence for JS/TS files and fails with an explicit
  "deps not installed -- run pnpm install" sentinel instead of silently
  letting a raw not-found surface downstream.
- packages/mosaic/framework/tools/qa/test-deps-preflight.sh: new
  regression harness (wired into test:framework-shell) covering missing
  node_modules/.bin, empty node_modules/.bin, populated node_modules/.bin,
  and non-JS/TS skip behavior.

Closes #856.
2026-07-20 04:16:30 -05:00
14 changed files with 239 additions and 2579 deletions

View File

@@ -4,26 +4,6 @@ These scripts provide host-aware GitHub and Gitea issue, pull-request, milestone
## Durable review provenance ## Durable review provenance
A successful provider write command—or a wrapper message based only on that command's exit code—is **not** durable review provenance. Review comments, approvals, and change requests count as durable provenance only after the wrapper reads the created provider record back and verifies that it was created by _this_ write. A successful provider write command—or a wrapper message based only on that command's exit code—is **not** durable review provenance. Review comments count as durable provenance only after the wrapper reads the created provider record back and verifies that it belongs to the intended repository and pull request and contains the exact submitted body (or verifies the provider-returned record ID).
**The write is a direct Gitea REST `POST` that returns the created record's id.** Neither wrapper writes through `tea` — tea 0.11.1 can silently no-op while exiting 0 and cannot emit the id of a record it creates, so its exit code is worthless as proof of a durable write (#865). Instead: `pr-review.sh` therefore fails closed when a Gitea comment cannot be written, its created comment ID cannot be identified, or provider read-back does not match. It reports comment success only after that read-back verification passes.
- Comments (`issue-comment.sh`, and the `comment` action of `pr-review.sh`) `POST /api/v1/repos/{owner}/{repo}/issues/{index}/comments`, requiring a `201` and parsing the created comment's `id` from the response body.
- Reviews (`approve` / `request-changes`) `POST /api/v1/repos/{owner}/{repo}/pulls/{index}/reviews` with the `event` (`APPROVED` / `REQUEST_CHANGES`), the review `body`, and `commit_id` pinned to the PR's current head, then parse the created review's `id`. The review body travels _in the review submit itself_ — there is no separate detached comment to reconcile (a Gitea `REQUEST_CHANGES` review requires a non-empty body, which the submit carries).
**Verification keys on that exact provider-returned id.** The wrapper then `GET`s that one record directly — `GET /issues/comments/{id}` or `GET /pulls/{n}/reviews/{id}` — and requires that its `id` equals the created id, its **author login equals the acting identity** (resolved via `GET /api/v1/user` for the token in use), and, for comments, its body exactly matches what was submitted **and its returned web URL belongs to this exact provider and repository** (the `issue_url` / `pull_request_url` origin — scheme, host, and effective port — and full path, i.e. deployment prefix + exact `owner/repo` + kind + number, must match; a suffix/`endsWith` test would accept a look-alike host or a decoy path prefix, so the whole normalized URL is compared), or, for reviews, its state matches the requested action, its reviewed `commit_id` equals the PR head, **and its persisted body equals the submitted body** (Gitea can finalize/reuse a pending review id whose stored content was authored elsewhere, so the body is bound too). The write, the `/user` identity lookup, and the read-back all use the **same** credential — the effective login's token, or the host credential when no login is named — so the write is verified against the identity that actually performed it.
**This closes the concurrency window rather than documenting it.** Because verification keys on the id the create returned, a no-op create yields no id and fails closed with no list-scan fallback, and a _concurrent_ record — even one written by the _same_ identity with an identical body/state — has a _different_ id and cannot be mistaken for this write. There is no residual same-identity window: the earlier boundary-and-author heuristic (accept any `id > pre-write-max` with a matching author) is replaced entirely by exact-id attribution.
**Exact-id read-back is the sole authority.** Verification is a direct `GET` of the one record the create returned; there is no follow-up list enumeration. An earlier redundant pass that re-listed the record's page (`?limit=&page=1,2,…`) was removed: server-capped page sizes and list-pagination quirks made it a false-failure source (a durable, exact-id-verified record could be missed by a non-exhaustive enumeration), and it added nothing over the authoritative exact-id `GET`.
## `tea` invocation notes (Gitea)
- tea v0.11.1 has **no `comment` subcommand under `tea pr` or `tea issue`** — the `tea pr comment` / `tea issue comment` forms don't error, they silently fall through to a no-op and still exit 0, producing a false-success write (#865). tea's write subcommands (`tea comment`, `tea pr approve`/`reject`) also cannot report the id of the record they create, so their exit code cannot prove a durable write. These wrappers therefore do **not** write reviews or comments through `tea` at all; they use direct Gitea REST `POST`s that return the created record's id (see "Durable review provenance" above). `tea` is consulted only to enumerate the login list for host→login resolution.
- Because the review body is carried in the `POST …/reviews` submit itself, there is no separate detached review comment, and the historical `tea pr approve`/`reject` trailing-positional-argument vs. nonexistent `--comment`/`-comment` flag hazard (#835) no longer applies to these wrappers — no review comment is ever passed to `tea`.
### `--login` override
Both `pr-review.sh` and `issue-comment.sh` accept an optional `--login <name>` flag that overrides the automatically detected Gitea login for that single invocation. The override selects **which credential the REST write, the `/user` identity lookup, and the read-back all use** — its token is resolved from the tea config for that login name (`get_gitea_token_for_login`), falling back to the repo host's credential when no login is named. The resolved login is **host- and port-bound**: the login's configured URL host **and effective port** (the scheme's default port — 80 for `http`, 443 for `https` — applies when a port is omitted, symmetrically on both sides) must match the repo remote's, so a login name shared across hosts (or an override configured for a different Gitea, including one on a different port of the same host) can never send one host's credential to another — a host or port mismatch fails closed rather than leaking a cross-host token. Resolving the acting identity and the read-back from the _same_ login that performs the write is essential: a write performed under an overridden login must be verified against that login's identity, not the host default's. Callers who need a different login than the host default should pass `--login <reviewer-login>`.
As a durable successor to this mechanism, consider giving each reviewer/approver slot its own dedicated Gitea login credential, so that author≠reviewer holds at the credential level rather than relying on wrapper-level `--login` bookkeeping. This is a recommendation for future hardening, not something implemented by this flag.

View File

@@ -91,19 +91,13 @@ remote = urlparse(f"//{remote_host}")
if configured.scheme not in {"http", "https"} or configured.hostname != remote.hostname: if configured.scheme not in {"http", "https"} or configured.hostname != remote.hostname:
raise SystemExit(1) raise SystemExit(1)
# Normalize by scheme: an implicit (portless) HTTP(S) URL and its explicit configured_port = configured.port
# default-port form (":80" for http, ":443" for https) name the same remote_port = remote.port
# provider endpoint. Apply that equivalence symmetrically -- whichever side if remote_port is None:
# omits the port is treated as carrying the scheme's default port -- so
# "configured implicit vs. remote explicit" and "configured explicit vs.
# remote implicit" both match. (The remote side here is always an HTTP(S)
# authority; an SSH remote's transport port is stripped by get_remote_host
# before reaching this comparison, since it identifies an unrelated
# service on the same host, not the HTTP(S) provider port.)
default_port = 80 if configured.scheme == "http" else 443 default_port = 80 if configured.scheme == "http" else 443
normalized_configured = configured.port if configured.port is not None else default_port if configured_port not in {None, default_port}:
normalized_remote = remote.port if remote.port is not None else default_port raise SystemExit(1)
if normalized_configured != normalized_remote: elif configured_port != remote_port:
raise SystemExit(1) raise SystemExit(1)
raise SystemExit(0) raise SystemExit(0)
PY PY
@@ -438,11 +432,7 @@ get_remote_host() {
fi fi
if [[ "$remote_url" =~ ^ssh://([^/]+)/ ]]; then if [[ "$remote_url" =~ ^ssh://([^/]+)/ ]]; then
local host="${BASH_REMATCH[1]}" local host="${BASH_REMATCH[1]}"
host="${host##*@}" echo "${host##*@}"
# Strip an SSH transport port (e.g. "git.example:2222"): it names the
# SSH daemon port, not the HTTP(S) provider API port, and must not
# feed gitea_url_matches_host's port comparison (#850).
echo "${host%%:*}"
return 0 return 0
fi fi
if [[ "$remote_url" =~ ^git@([^:]+): ]]; then if [[ "$remote_url" =~ ^git@([^:]+): ]]; then
@@ -563,213 +553,6 @@ get_gitea_token() {
return 1 return 1
} }
# Resolve the API token for a SPECIFIC tea login name from tea's own config
# (the same store tea itself writes/reads for `--login <name>`). This is what
# lets a REST write be performed AS the selected --login identity: tea keys its
# per-login tokens by `name` in $XDG_CONFIG_HOME/tea/config.yml (default
# ~/.config/tea/config.yml), exactly as the `tea` CLI resolves them, so a
# --login override and its REST read-back bind to the SAME credential/identity.
#
# $2 (repo host) binds the selected credential to the TARGET host: a tea login
# also records the `url` it authenticates against, and the matched login's URL
# host MUST equal the repo host. This fails closed when an override login is
# configured for a DIFFERENT host than the repo remote, so a login name shared
# across hosts (or a mistargeted override) can never send one host's credential
# to another host (cross-host credential leak). When $2 is empty the host bind
# is skipped (host-agnostic lookup) — callers that write should always pass it.
#
# Prints the token on success; returns non-zero (no output) if the config, a
# matching login token, or the host bind cannot be satisfied. Callers must not
# log the result.
get_gitea_token_for_login() {
local login_name="$1" repo_host="${2:-}" config_file
[[ -n "$login_name" ]] || return 1
config_file="${XDG_CONFIG_HOME:-$HOME/.config}/tea/config.yml"
[[ -f "$config_file" ]] || return 1
LOGIN_NAME="$login_name" REPO_HOST="$repo_host" python3 - "$config_file" <<'PY'
import os
import re
import sys
from urllib.parse import urlparse
wanted = os.environ["LOGIN_NAME"]
repo_host = os.environ.get("REPO_HOST", "").strip().lower()
config_path = sys.argv[1]
def _strip_scalar(value):
# Resolve a YAML flow scalar the way PyYAML would for tea's simple scalars:
# honor surrounding quotes and strip a trailing inline comment. A quoted
# scalar keeps its literal contents (any '#' inside is data, not a comment);
# an unquoted scalar ends at the first whitespace-preceded '#' (a YAML
# comment must be preceded by whitespace or line start), so "abc#def" stays
# literal while "abc # note" becomes "abc".
value = value.strip()
if not value:
return value
if value[0] in ("'", '"'):
quote = value[0]
end = value.find(quote, 1)
if end != -1:
return value[1:end]
# Unterminated quote: PyYAML would error; return best-effort remainder so
# the (more conservative) caller still compares against the wanted name.
return value[1:]
for index, char in enumerate(value):
if char == "#" and (index == 0 or value[index - 1] in (" ", "\t")):
value = value[:index]
break
return value.strip()
def _url_matches_repo_host(url):
# Mirror gitea_url_matches_host (detect-platform.sh): the login's recorded
# URL must name the SAME host AND the SAME effective port as the repo remote
# host, not merely the same hostname. A login configured for an explicit,
# non-default provider port (e.g. :9443) must NOT satisfy a portless (default
# -port) repo host, and a login on the matching port (e.g. :8443) must NOT be
# rejected. Ports are normalized by applying the login URL's scheme default
# (80 for http, else 443) to whichever side omits the port, symmetrically, so
# an implicit port and its explicit default-port form compare equal.
if not isinstance(url, str) or not url:
return False
configured = urlparse(url if "//" in url else f"//{url}")
remote = urlparse(f"//{repo_host}")
configured_host = configured.hostname
if not configured_host or configured_host.lower() != (remote.hostname or "").lower():
return False
default_port = 80 if configured.scheme == "http" else 443
configured_port = configured.port if configured.port is not None else default_port
remote_port = remote.port if remote.port is not None else default_port
return configured_port == remote_port
def _accept(token, url):
# Enforce the host bind before surfacing a token. When a repo host is given,
# the login's recorded URL host AND port must match it; a login with no
# usable URL (or a mismatched host/port) is rejected (fail closed) so a
# cross-host (or cross-port) credential is never emitted.
if not isinstance(token, str) or not token:
return None
if repo_host:
if not _url_matches_repo_host(url):
return None
return token
def _token_via_pyyaml():
# Preferred, fully general path when PyYAML is installed. Raises ImportError
# (caught by the caller) when the module is unavailable so the environment
# -robust fallback can take over instead of failing closed on every host
# that lacks PyYAML.
import yaml
with open(config_path, encoding="utf-8") as handle:
config = yaml.safe_load(handle)
logins = config.get("logins") if isinstance(config, dict) else None
if not isinstance(logins, list):
return None
for login in logins:
if isinstance(login, dict) and str(login.get("name") or "") == wanted:
return _accept(login.get("token"), login.get("url"))
return None
def _token_via_lines():
# Conservative fallback for hosts without PyYAML. tea writes config.yml in a
# fixed, flat shape (a `logins:` list of maps with scalar name/url/token
# fields). This scan is SCOPE-AWARE: a field is attributed to a login entry
# ONLY when it sits at that entry's own direct-field indentation. A field
# nested inside a deeper sub-map (e.g. `extra:\n token: X`) or a mis-indented
# line is NEVER attached to the entry — exactly the cases where PyYAML resolves
# the entry's own `token` to None (or errors) and thus fails closed. Likewise
# only list items at the login list's own dash indent open a new entry, so a
# nested list item cannot masquerade as a sibling login. It returns the
# `token` of the entry whose `name` EXACTLY equals the requested login AND
# whose url host/port matches the repo host; anything else yields None (fail
# closed). This can only ever be MORE conservative than PyYAML (it never
# selects a token where PyYAML would refuse), never less.
with open(config_path, encoding="utf-8") as handle:
lines = handle.read().splitlines()
logins_indent = None
start = len(lines)
for index, line in enumerate(lines):
match = re.match(r"^(\s*)logins\s*:\s*$", line)
if match:
logins_indent = len(match.group(1))
start = index + 1
break
if logins_indent is None:
return None
entries = []
current = None
item_indent = None # dash column of the login list's own items
field_indent = None # exact column of the current entry's direct fields
for line in lines[start:]:
if not line.strip() or line.lstrip().startswith("#"):
continue
indent = len(line) - len(line.lstrip(" "))
if indent <= logins_indent:
break
item = re.match(r"^(\s*)-(\s*)(.*)$", line)
if item:
dash_indent = len(item.group(1))
if item_indent is None:
item_indent = dash_indent
if dash_indent != item_indent:
# A more-deeply-indented (nested) or dedented list item: not a
# direct login entry. Ignore it and its scope.
continue
current = {}
entries.append(current)
content = item.group(3)
if content:
# First field shares this line; its column is the direct-field
# indent for the rest of the entry.
field_indent = dash_indent + 1 + len(item.group(2))
pair = re.match(r"^([A-Za-z0-9_]+)\s*:\s*(.*)$", content)
if pair:
current[pair.group(1)] = _strip_scalar(pair.group(2))
else:
# Bare "-": the first following field line establishes the indent.
field_indent = None
continue
if current is None:
continue
if field_indent is None:
field_indent = indent
if indent != field_indent:
# Deeper => a nested sub-map's field (not this entry's own); anything
# else at an unexpected column is not a direct field. Skip either way.
continue
pair = re.match(r"^([A-Za-z0-9_]+)\s*:\s*(.*)$", line.strip())
if pair:
current[pair.group(1)] = _strip_scalar(pair.group(2))
for entry in entries:
if str(entry.get("name") or "") == wanted:
return _accept(entry.get("token"), entry.get("url"))
return None
try:
try:
token = _token_via_pyyaml()
except ImportError:
token = _token_via_lines()
except Exception:
raise SystemExit(1)
if isinstance(token, str) and token:
print(token)
raise SystemExit(0)
raise SystemExit(1)
PY
}
# Resolve HTTPS basic auth credentials for a Gitea host from ~/.git-credentials. # Resolve HTTPS basic auth credentials for a Gitea host from ~/.git-credentials.
# Prints "username:password" for direct curl -u consumption. Callers must not log it. # Prints "username:password" for direct curl -u consumption. Callers must not log it.
get_gitea_basic_auth() { get_gitea_basic_auth() {

View File

@@ -1,26 +1,6 @@
#!/bin/bash #!/bin/bash
# issue-comment.sh - Add a comment to an issue on GitHub or Gitea # issue-comment.sh - Add a comment to an issue on GitHub or Gitea
# Usage: issue-comment.sh -i <issue_number> -c <comment> [--login <name>] # Usage: issue-comment.sh -i <issue_number> -c <comment>
#
# 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 set -e
@@ -30,7 +10,6 @@ source "$SCRIPT_DIR/detect-platform.sh"
# Parse arguments # Parse arguments
ISSUE_NUMBER="" ISSUE_NUMBER=""
COMMENT="" COMMENT=""
LOGIN_OVERRIDE=""
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
case $1 in case $1 in
@@ -42,17 +21,12 @@ while [[ $# -gt 0 ]]; do
COMMENT="$2" COMMENT="$2"
shift 2 shift 2
;; ;;
-l|--login)
LOGIN_OVERRIDE="$2"
shift 2
;;
-h|--help) -h|--help)
echo "Usage: issue-comment.sh -i <issue_number> -c <comment> [--login <name>]" echo "Usage: issue-comment.sh -i <issue_number> -c <comment>"
echo "" echo ""
echo "Options:" echo "Options:"
echo " -i, --issue Issue number (required)" echo " -i, --issue Issue number (required)"
echo " -c, --comment Comment text (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" echo " -h, --help Show this help"
exit 0 exit 0
;; ;;
@@ -75,263 +49,20 @@ fi
detect_platform >/dev/null 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
GITEA_API_TOKEN=$(get_gitea_token_for_login "$effective_login" "$host") \
|| GITEA_API_TOKEN=$(get_gitea_token "$host") || {
echo "Error: Gitea token not found for login '$effective_login' (comment write/read-back)" >&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 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
}
# 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 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")
trap 'rm -f "$write_file" "$readback_file"' RETURN
if ! write_status=$(curl -sS -o "$write_file" -w '%{http_code}' \
-X POST \
-H "Authorization: token $GITEA_API_TOKEN" \
-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}' \
-H "Authorization: token $GITEA_API_TOKEN" \
"$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, host, effective-port) + comment path. The port
# defaults to the scheme's default (80 http / 443 otherwise) so an implicit
# port and its explicit default form compare equal.
parsed = urlparse(url or "")
scheme = (parsed.scheme or "").lower()
host = (parsed.hostname or "").lower()
default_port = 80 if scheme == "http" else 443
port = parsed.port if parsed.port is not None else default_port
return (scheme, 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 if [[ "$PLATFORM" == "github" ]]; then
gh issue comment "$ISSUE_NUMBER" --body "$COMMENT" gh issue comment "$ISSUE_NUMBER" --body "$COMMENT"
echo "Added comment to GitHub issue #$ISSUE_NUMBER" echo "Added comment to GitHub issue #$ISSUE_NUMBER"
elif [[ "$PLATFORM" == "gitea" ]]; then elif [[ "$PLATFORM" == "gitea" ]]; then
# Resolve the login this comment should be attributed to: the --login # Build the invocation as an argv array (not unquoted $(get_gitea_repo_args)
# override when given, otherwise the detected default for this repo's host. # word-splitting) so the comment body — including Markdown backticks, $(...),
# A --login override always wins. Otherwise name this repo host's login only # and quotes — is passed verbatim and never re-split or shell-evaluated.
# as a best effort: the login name merely selects a per-login token, and REPO_SLUG=$(get_repo_slug)
# gitea_resolve_api_for_login falls back to the host credential GITEA_LOGIN_NAME=$(get_gitea_login) || {
# (get_gitea_token) when no tea login is named, so the default credential echo "Error: could not resolve a Gitea login for this repo; cannot comment on issue #$ISSUE_NUMBER." >&2
# still resolves even when the host tea has no matching login entry.
EFFECTIVE_LOGIN="$LOGIN_OVERRIDE"
[[ -n "$EFFECTIVE_LOGIN" ]] || EFFECTIVE_LOGIN=$(get_gitea_login 2>/dev/null || true)
# 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 exit 1
} }
echo "Added and verified comment on Gitea issue #$ISSUE_NUMBER (comment ID $comment_id)" tea issue comment "$ISSUE_NUMBER" "$COMMENT" --repo "$REPO_SLUG" --login "$GITEA_LOGIN_NAME"
echo "Added comment to Gitea issue #$ISSUE_NUMBER"
else else
echo "Error: Unknown platform" echo "Error: Unknown platform"
exit 1 exit 1

View File

@@ -1,21 +1,6 @@
#!/bin/bash #!/bin/bash
# pr-review.sh - Review a pull request on GitHub or Gitea # pr-review.sh - Review a pull request on GitHub or Gitea
# Usage: pr-review.sh -n <pr_number> -a <action> [-c <comment>] [--login <name>] # Usage: pr-review.sh -n <pr_number> -a <action> [-c <comment>]
#
# Gitea reviews and comments are written through the supported REST API, not
# `tea`: tea 0.11.1 cannot emit the id of a record it creates and can silently
# no-op while exiting 0 (#865 defect class), so an exit code is the only — and
# untrustworthy — signal it offers. approve/request-changes POST to
# /pulls/{n}/reviews (returns the created review with its id); the `comment`
# action POSTs to /issues/{n}/comments (returns the created comment with its
# id). Each write is then verified by GETting that exact returned id, so a
# concurrent record cannot masquerade as this write and a no-op fails closed.
#
# --login override: the default login is resolved from the local tea login list
# for this repo's host (get_gitea_login_for_host). Pass --login <name> to
# override it for this invocation only. The REST write, the /user identity read,
# and every read-back are ALL performed with the token of the EFFECTIVE login,
# so the write and its verification bind to the same identity.
set -e set -e
@@ -27,7 +12,6 @@ source "$SCRIPT_DIR/detect-platform.sh"
PR_NUMBER="" PR_NUMBER=""
ACTION="" ACTION=""
COMMENT="" COMMENT=""
LOGIN_OVERRIDE=""
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
case $1 in case $1 in
@@ -43,18 +27,13 @@ while [[ $# -gt 0 ]]; do
COMMENT="$2" COMMENT="$2"
shift 2 shift 2
;; ;;
-l|--login)
LOGIN_OVERRIDE="$2"
shift 2
;;
-h|--help) -h|--help)
echo "Usage: pr-review.sh -n <pr_number> -a <action> [-c <comment>] [--login <name>]" echo "Usage: pr-review.sh -n <pr_number> -a <action> [-c <comment>]"
echo "" echo ""
echo "Options:" echo "Options:"
echo " -n, --number PR number (required)" echo " -n, --number PR number (required)"
echo " -a, --action Review action: approve, request-changes, comment (required)" echo " -a, --action Review action: approve, request-changes, comment (required)"
echo " -c, --comment Review comment (required for request-changes)" echo " -c, --comment Review comment (required for request-changes)"
echo " -l, --login Override the detected Gitea tea login (approve/request-changes only)"
echo " -h, --help Show this help" echo " -h, --help Show this help"
exit 0 exit 0
;; ;;
@@ -77,385 +56,6 @@ fi
detect_platform >/dev/null detect_platform >/dev/null
# Post a comment to a Gitea PR (PR comments ARE issue comments) via the
# supported REST API and verify it against a PROVIDER-RETURNED created id. The
# write is a direct POST that returns the created comment object, so we learn
# the exact id of THIS write; we GET that exact id and require id == created id
# AND author == acting identity AND exact body AND that it belongs to this PR.
# Keying to the returned id means no concurrent comment (even same identity /
# body) can masquerade as this write, and a no-op create yields no id and fails
# closed. Requires GITEA_API_BASE / GITEA_API_TOKEN to be resolved first (via
# gitea_resolve_api_for_login). Prints the created comment id on success.
#
# Args: $1 = PR number, $2 = comment body, $3 = acting identity login.
gitea_create_comment_verified() {
local pr_number="$1" comment_body="$2" acting_login="$3"
local payload write_file readback_file 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-pr-review-write.XXXXXX")
readback_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-pr-review-getid.XXXXXX")
trap 'rm -f "$write_file" "$readback_file"' RETURN
if ! write_status=$(curl -sS -o "$write_file" -w '%{http_code}' \
-X POST \
-H "Authorization: token $GITEA_API_TOKEN" \
-H 'Content-Type: application/json' \
-d "$payload" \
"$GITEA_API_BASE/issues/$pr_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" >&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}' \
-H "Authorization: token $GITEA_API_TOKEN" \
"$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="$pr_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, host, effective-port) + comment path. The port
# defaults to the scheme's default (80 http / 443 otherwise) so an implicit
# port and its explicit default form compare equal.
parsed = urlparse(url or "")
scheme = (parsed.scheme or "").lower()
host = (parsed.hostname or "").lower()
default_port = 80 if scheme == "http" else 443
port = parsed.port if parsed.port is not None else default_port
return (scheme, 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 PR-conversation
# comment carries pull_request_url = <web_base>/<owner>/<repo>/pulls/<n> (with
# issue_url empty), while a plain issue comment carries
# issue_url = <web_base>/<owner>/<repo>/issues/<n> (with pull_request_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>/pulls/N) or a same-host
# decoy prefix (/other/<slug>/pulls/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 PR 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
}
# 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 the one credential used to submit
# the review/comment ALSO drives the /user identity read and every 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 submitted
# 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 review/comment to the host default
# identity. 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 (review write/read-back)" >&2
return 1
}
else
GITEA_API_TOKEN=$(get_gitea_token_for_login "$effective_login" "$host") \
|| GITEA_API_TOKEN=$(get_gitea_token "$host") || {
echo "Error: Gitea token not found for login '$effective_login' (review write/read-back)" >&2
return 1
}
fi
configured_url=$(get_gitea_url_for_host "$host") || {
echo "Error: Configured Gitea URL not found for review 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/PR 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 review to THIS action's reviewer so a
# concurrent review 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-pr-review-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
}
# Resolve the PR's current head commit SHA (GET /pulls/{n}). The review is
# submitted against — and later verified as pinned to — this exact commit, so a
# stale review left over from an earlier push cannot be mistaken for this one.
# Prints the head SHA on success.
gitea_pr_head_sha() {
local pr_number="$1" pr_file status
pr_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-pr-review-head.XXXXXX")
trap 'rm -f "$pr_file"' RETURN
if ! status=$(curl -sS -o "$pr_file" -w '%{http_code}' \
-H "Authorization: token $GITEA_API_TOKEN" \
"$GITEA_API_BASE/pulls/$pr_number"); then
echo "Error: Gitea PR head read transport failed" >&2
return 1
fi
if [[ "$status" != "200" ]]; then
echo "Error: Gitea PR head read failed with HTTP $status" >&2
return 1
fi
python3 - "$pr_file" <<'PY'
import json
import sys
try:
with open(sys.argv[1], encoding="utf-8") as response:
pr = json.load(response)
head_sha = pr.get("head", {}).get("sha") if isinstance(pr, dict) else None
if not isinstance(head_sha, str) or not head_sha:
raise ValueError("missing PR head sha")
except (OSError, json.JSONDecodeError, AttributeError, TypeError, ValueError) as error:
print(f"Error: could not resolve PR head commit: {error}", file=sys.stderr)
raise SystemExit(1)
print(head_sha)
PY
}
# Submit a review to a Gitea PR via the supported REST API and verify it against
# a PROVIDER-RETURNED created id. tea 0.11.1's `pr approve`/`reject` cannot emit
# the id of the review it created and can silently no-op while exiting 0 (#865
# defect class), so this does NOT shell out to tea: it POSTs to
# /pulls/{n}/reviews with the event (APPROVED / REQUEST_CHANGES), the PR head
# commit_id, and the review body, which returns the created review object
# including its id. It then GETs that exact review id and requires
# id == created id AND author == acting identity AND state == expected AND
# commit_id == PR head. Keying to the returned id means no concurrent review
# (even same identity/state/head) can masquerade as this one, and a no-op
# submit yields no id and fails closed. Prints the created review id on success.
#
# Args: $1 = PR number, $2 = event (APPROVED|REQUEST_CHANGES),
# $3 = review body (may be empty for APPROVED), $4 = acting login,
# $5 = PR head sha.
gitea_submit_review_verified() {
local pr_number="$1" event="$2" review_body="$3" acting_login="$4" head_sha="$5"
local payload write_file readback_file write_status readback_status created_id
payload=$(REVIEW_EVENT="$event" REVIEW_BODY="$review_body" REVIEW_COMMIT="$head_sha" python3 -c '
import json
import os
print(json.dumps({
"event": os.environ["REVIEW_EVENT"],
"body": os.environ["REVIEW_BODY"],
"commit_id": os.environ["REVIEW_COMMIT"],
}))
')
write_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-pr-review-submit.XXXXXX")
readback_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-pr-review-getid.XXXXXX")
trap 'rm -f "$write_file" "$readback_file"' RETURN
if ! write_status=$(curl -sS -o "$write_file" -w '%{http_code}' \
-X POST \
-H "Authorization: token $GITEA_API_TOKEN" \
-H 'Content-Type: application/json' \
-d "$payload" \
"$GITEA_API_BASE/pulls/$pr_number/reviews"); then
echo "Error: Gitea review submit transport failed" >&2
return 1
fi
# Gitea returns 200 (occasionally 201) with the created review object.
if [[ "$write_status" != "200" && "$write_status" != "201" ]]; then
echo "Error: Gitea review submit failed with HTTP $write_status (#865: no durable review 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:
review = json.load(response)
created_id = review.get("id") if isinstance(review, dict) else None
if not isinstance(created_id, int) or created_id <= 0:
raise ValueError("submit response carried no positive review id")
except (OSError, json.JSONDecodeError, ValueError) as error:
print(f"Error: could not identify created Gitea review: {error}", file=sys.stderr)
raise SystemExit(1)
print(created_id)
PY
) || return 1
if ! readback_status=$(curl -sS -o "$readback_file" -w '%{http_code}' \
-H "Authorization: token $GITEA_API_TOKEN" \
"$GITEA_API_BASE/pulls/$pr_number/reviews/$created_id"); then
echo "Error: Gitea review read-back transport failed" >&2
return 1
fi
if [[ "$readback_status" != "200" ]]; then
echo "Error: Gitea review read-back failed with HTTP $readback_status" >&2
return 1
fi
EXPECTED_REVIEW_ID="$created_id" EXPECTED_STATE="$event" ACTING_LOGIN="$acting_login" \
EXPECTED_HEAD_SHA="$head_sha" EXPECTED_REVIEW_BODY="$review_body" \
python3 - "$readback_file" <<'PY' || return 1
import json
import os
import sys
try:
with open(sys.argv[1], encoding="utf-8") as response:
review = json.load(response)
if not isinstance(review, dict):
raise ValueError("response is not a review object")
expected_id = int(os.environ["EXPECTED_REVIEW_ID"])
expected_state = os.environ["EXPECTED_STATE"]
acting_login = os.environ["ACTING_LOGIN"]
expected_head = os.environ["EXPECTED_HEAD_SHA"]
expected_body = os.environ["EXPECTED_REVIEW_BODY"]
if review.get("id") != expected_id:
raise ValueError("read-back id does not match the created id")
if (review.get("user") or {}).get("login") != acting_login:
raise ValueError("created review is not authored by the acting identity")
if review.get("state") != expected_state:
raise ValueError("created review is not in the expected state")
if review.get("commit_id") != expected_head:
raise ValueError("created review is not pinned to the PR head commit")
# Bind to the exact submitted body. On Gitea v1.25.4 SubmitReview may
# finalize/reuse a pending review id whose Content was authored elsewhere;
# the exact GET exposes the persisted body, so a mismatch (a reused/foreign
# review carrying different Content) fails closed even when id/author/state/
# head all line up.
if (review.get("body") or "") != expected_body:
raise ValueError("created review body does not match the submitted body")
except (OSError, json.JSONDecodeError, KeyError, TypeError, ValueError) as error:
print(f"Error: Gitea review persistence verification failed: {error}", file=sys.stderr)
raise SystemExit(1)
PY
echo "$created_id"
return 0
}
if [[ "$PLATFORM" == "github" ]]; then if [[ "$PLATFORM" == "github" ]]; then
case $ACTION in case $ACTION in
approve) approve)
@@ -486,76 +86,124 @@ if [[ "$PLATFORM" == "github" ]]; then
elif [[ "$PLATFORM" == "gitea" ]]; then elif [[ "$PLATFORM" == "gitea" ]]; then
case $ACTION in case $ACTION in
approve) approve)
repo=$(get_repo_slug)
host=$(get_remote_host) host=$(get_remote_host)
# A --login override always wins. Otherwise name this host's login login=$(get_gitea_login_for_host "$host")
# only as a best effort: the login name merely selects a per-login tea pr approve "$PR_NUMBER" --repo "$repo" --login "$login" ${COMMENT:+--comment "$COMMENT"}
# token, and gitea_resolve_api_for_login falls back to the host echo "Approved Gitea PR #$PR_NUMBER"
# credential (get_gitea_token) when no tea login is named — so a host
# tea's login list need not enumerate exotic (e.g. ported) hosts for
# the default credential to resolve. The single resolved token is
# then used for the write, the /user identity, and the read-back.
EFFECTIVE_LOGIN="$LOGIN_OVERRIDE"
[[ -n "$EFFECTIVE_LOGIN" ]] || EFFECTIVE_LOGIN=$(get_gitea_login_for_host "$host" 2>/dev/null || true)
# Bind the REST endpoint + token to the effective login, then derive
# the acting identity from that SAME credential so the review submit
# and its read-back verify against the identity that performed them.
gitea_resolve_api_for_login "$EFFECTIVE_LOGIN" "${LOGIN_OVERRIDE:+explicit}" || exit 1
ACTING_LOGIN=$(gitea_authenticated_login) || exit 1
head_sha=$(gitea_pr_head_sha "$PR_NUMBER") || exit 1
# The review body (if any) travels with the review itself in the REST
# submit — the created review record carries it — so there is no
# separate detached comment to reconcile.
review_id=$(gitea_submit_review_verified "$PR_NUMBER" "APPROVED" "$COMMENT" "$ACTING_LOGIN" "$head_sha") || {
echo "Error: could not submit and verify an APPROVED review on Gitea PR #$PR_NUMBER via a provider-returned created id (#865)." >&2
exit 1
}
echo "Approved and verified Gitea PR #$PR_NUMBER (review ID $review_id)"
;; ;;
request-changes) request-changes)
if [[ -z "$COMMENT" ]]; then if [[ -z "$COMMENT" ]]; then
echo "Error: Comment required for request-changes" echo "Error: Comment required for request-changes"
exit 1 exit 1
fi fi
repo=$(get_repo_slug)
host=$(get_remote_host) host=$(get_remote_host)
# A --login override always wins. Otherwise name this host's login login=$(get_gitea_login_for_host "$host")
# only as a best effort: the login name merely selects a per-login tea pr reject "$PR_NUMBER" --repo "$repo" --login "$login" --comment "$COMMENT"
# token, and gitea_resolve_api_for_login falls back to the host echo "Requested changes on Gitea PR #$PR_NUMBER"
# credential (get_gitea_token) when no tea login is named — so a host
# tea's login list need not enumerate exotic (e.g. ported) hosts for
# the default credential to resolve. The single resolved token is
# then used for the write, the /user identity, and the read-back.
EFFECTIVE_LOGIN="$LOGIN_OVERRIDE"
[[ -n "$EFFECTIVE_LOGIN" ]] || EFFECTIVE_LOGIN=$(get_gitea_login_for_host "$host" 2>/dev/null || true)
gitea_resolve_api_for_login "$EFFECTIVE_LOGIN" "${LOGIN_OVERRIDE:+explicit}" || exit 1
ACTING_LOGIN=$(gitea_authenticated_login) || exit 1
head_sha=$(gitea_pr_head_sha "$PR_NUMBER") || exit 1
review_id=$(gitea_submit_review_verified "$PR_NUMBER" "REQUEST_CHANGES" "$COMMENT" "$ACTING_LOGIN" "$head_sha") || {
echo "Error: could not submit and verify a REQUEST_CHANGES review on Gitea PR #$PR_NUMBER via a provider-returned created id (#865)." >&2
exit 1
}
echo "Requested changes and verified on Gitea PR #$PR_NUMBER (review ID $review_id)"
;; ;;
comment) comment)
if [[ -z "$COMMENT" ]]; then if [[ -z "$COMMENT" ]]; then
echo "Error: Comment required" echo "Error: Comment required"
exit 1 exit 1
fi fi
host=$(get_remote_host) host=$(get_remote_host)
# A --login override always wins. Otherwise name this host's login token=$(get_gitea_token "$host") || {
# only as a best effort: the login name merely selects a per-login echo "Error: Gitea token not found for comment persistence" >&2
# token, and gitea_resolve_api_for_login falls back to the host
# credential (get_gitea_token) when no tea login is named — so a host
# tea's login list need not enumerate exotic (e.g. ported) hosts for
# the default credential to resolve. The single resolved token is
# then used for the write, the /user identity, and the read-back.
EFFECTIVE_LOGIN="$LOGIN_OVERRIDE"
[[ -n "$EFFECTIVE_LOGIN" ]] || EFFECTIVE_LOGIN=$(get_gitea_login_for_host "$host" 2>/dev/null || true)
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 "$PR_NUMBER" "$COMMENT" "$ACTING_LOGIN") || {
echo "Error: could not create and verify a comment on Gitea PR #$PR_NUMBER via a provider-returned created id (#865)." >&2
exit 1 exit 1
} }
configured_url=$(get_gitea_url_for_host "$host") || {
echo "Error: Configured Gitea URL not found for comment persistence" >&2
exit 1
}
repo=$(get_gitea_repo_slug_for_url "$configured_url") || {
echo "Error: Could not resolve Gitea owner/repository relative to configured URL" >&2
exit 1
}
api_base="${configured_url%/}/api/v1/repos/$repo"
payload=$(COMMENT_BODY="$COMMENT" python3 -c '
import json
import os
print(json.dumps({"body": os.environ["COMMENT_BODY"]}))
')
write_response_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-pr-review-write.XXXXXX")
readback_response_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-pr-review-readback.XXXXXX")
trap 'rm -f "$write_response_file" "$readback_response_file"' EXIT
if ! write_status=$(curl -sS -o "$write_response_file" -w '%{http_code}' \
-X POST \
-H "Authorization: token $token" \
-H 'Content-Type: application/json' \
-d "$payload" \
"$api_base/issues/$PR_NUMBER/comments"); then
echo "Error: Gitea comment write transport failed" >&2
exit 1
fi
if [[ "$write_status" != "201" ]]; then
echo "Error: Gitea comment write failed with HTTP $write_status" >&2
exit 1
fi
comment_id=$(python3 - "$write_response_file" <<'PY'
import json
import sys
try:
with open(sys.argv[1], encoding="utf-8") as response:
comment = json.load(response)
comment_id = comment.get("id") if isinstance(comment, dict) else None
if not isinstance(comment_id, int) or comment_id <= 0:
raise ValueError("missing 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(comment_id)
PY
)
if ! readback_status=$(curl -sS -o "$readback_response_file" -w '%{http_code}' \
-H "Authorization: token $token" \
"$api_base/issues/comments/$comment_id"); then
echo "Error: Gitea comment read-back transport failed" >&2
exit 1
fi
if [[ "$readback_status" != "200" ]]; then
echo "Error: Gitea comment read-back failed with HTTP $readback_status" >&2
exit 1
fi
EXPECTED_COMMENT_ID="$comment_id" EXPECTED_COMMENT_BODY="$COMMENT" EXPECTED_REPO="$repo" EXPECTED_PR_NUMBER="$PR_NUMBER" \
python3 - "$readback_response_file" <<'PY'
import json
import os
import sys
from urllib.parse import urlparse
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"]
expected_repo = os.environ["EXPECTED_REPO"]
expected_pr = os.environ["EXPECTED_PR_NUMBER"]
issue_path = urlparse(comment.get("issue_url", "")).path.rstrip("/")
expected_suffix = f"/repos/{expected_repo}/issues/{expected_pr}"
if comment.get("id") != expected_id:
raise ValueError("comment id mismatch")
if comment.get("body") != expected_body:
raise ValueError("comment body mismatch")
if not issue_path.endswith(expected_suffix):
raise ValueError("repository or PR mismatch")
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 "Added and verified comment on Gitea PR #$PR_NUMBER (comment ID $comment_id)" echo "Added and verified comment on Gitea PR #$PR_NUMBER (comment ID $comment_id)"
;; ;;
*) *)

View File

@@ -312,132 +312,4 @@ if [[ "$override_wins" != "mosaicstack" ]]; then
fi fi
git -C "$REPO_DIR" remote set-url origin https://git.uscllc.com/USC/uconnect.git git -C "$REPO_DIR" remote set-url origin https://git.uscllc.com/USC/uconnect.git
# ---------------------------------------------------------------------------
# #865 Blocker 1 & 2: get_gitea_token_for_login must resolve the SAME token as
# PyYAML would (or fail closed identically) even when PyYAML is ABSENT, and must
# bind the credential to the repo host's scheme + host + EFFECTIVE PORT — not the
# hostname alone. These fixtures probe the ImportError-dispatched line-parser
# fallback under FORCED PyYAML absence with adversarial YAML shapes, asserting it
# NEVER misattributes a token from a nested sub-map or a mis-indented line, strips
# inline comments like PyYAML, fails closed where PyYAML errors, and rejects a
# port mismatch while accepting an exact / default-port match. When PyYAML is
# available the same fixtures also assert the PyYAML path agrees (equivalence).
# ---------------------------------------------------------------------------
FIXTURE_XDG="$WORK_DIR/tokenfix"
NOYAML_DIR="$WORK_DIR/noyaml"
mkdir -p "$FIXTURE_XDG/tea" "$NOYAML_DIR"
# A shadow `yaml` module that raises ImportError, forcing the fallback path.
printf 'raise ImportError("forced-absent for #865 fallback regression")\n' > "$NOYAML_DIR/yaml.py"
if python3 -c 'import yaml' >/dev/null 2>&1; then HAVE_PYYAML=true; else HAVE_PYYAML=false; fi
# Confirm the shim really does force ImportError, so the fallback is exercised.
if python3 -c 'import yaml' >/dev/null 2>&1; then
if PYTHONPATH="$NOYAML_DIR" python3 -c 'import yaml' >/dev/null 2>&1; then
echo "FAIL: PyYAML-absence shim did not force ImportError (fallback not exercised)" >&2
exit 1
fi
fi
write_fixture() { printf '%s' "$1" > "$FIXTURE_XDG/tea/config.yml"; }
# Resolve a token via the FORCED-fallback path (PyYAML shimmed to ImportError).
token_fallback() {
(
cd "$REPO_DIR"
XDG_CONFIG_HOME="$FIXTURE_XDG" PYTHONPATH="$NOYAML_DIR" bash -c '
source "'"$SCRIPT_DIR"'/detect-platform.sh"
get_gitea_token_for_login "$1" "$2"
' _ "$1" "$2"
) 2>/dev/null || true
}
# Resolve a token via the normal path (uses PyYAML when installed).
token_pyyaml() {
(
cd "$REPO_DIR"
XDG_CONFIG_HOME="$FIXTURE_XDG" bash -c '
source "'"$SCRIPT_DIR"'/detect-platform.sh"
get_gitea_token_for_login "$1" "$2"
' _ "$1" "$2"
) 2>/dev/null || true
}
assert_token() {
local desc="$1" expected="$2" login="$3" host="$4" got
got=$(token_fallback "$login" "$host")
if [[ "$got" != "$expected" ]]; then
echo "FAIL fallback [$desc]: expected [$expected] got [$got]" >&2
exit 1
fi
if [[ "$HAVE_PYYAML" == true ]]; then
got=$(token_pyyaml "$login" "$host")
if [[ "$got" != "$expected" ]]; then
echo "FAIL pyyaml [$desc]: expected [$expected] got [$got]" >&2
exit 1
fi
fi
}
# 1. Plain, well-formed entry resolves its token.
write_fixture 'logins:
- name: primary
url: https://git.example
token: TOK_PLAIN
'
assert_token "plain scalar" "TOK_PLAIN" primary git.example
# 2. A token nested inside a deeper SUB-MAP must NOT attach to the entry — PyYAML
# resolves the entry's own token to None here, so the fallback must too.
write_fixture 'logins:
- name: primary
url: https://git.example
extra:
token: TOK_NESTED_ATTACKER
- name: other
url: https://git.example
token: TOK_OTHER
'
assert_token "nested sub-map token is not attributed" "" primary git.example
assert_token "sibling entry still resolves its own token" "TOK_OTHER" other git.example
# 3. A MIS-INDENTED token line (deeper than the entry's fields) must not attach;
# PyYAML errors on this shape, so both fail closed.
write_fixture 'logins:
- name: primary
url: https://git.example
token: TOK_MISINDENT
'
assert_token "mis-indented token fails closed" "" primary git.example
# 4. A trailing inline comment on a scalar is stripped, exactly as PyYAML does.
write_fixture 'logins:
- name: primary
url: https://git.example
token: TOK_INLINE # trailing note
'
assert_token "inline comment stripped" "TOK_INLINE" primary git.example
# 5. A PyYAML-fail-closed case: tab indentation. PyYAML raises a scanner error;
# the fallback resolves no token. Both fail closed identically.
write_fixture "$(printf 'logins:\n - name: primary\n url: https://git.example\n\ttoken: TOK_TAB\n')"
assert_token "tab-indent fails closed like PyYAML" "" primary git.example
# 6. Host binding is scheme + host + EFFECTIVE PORT, not hostname alone.
write_fixture 'logins:
- name: ported
url: https://git.example:8443
token: TOK_PORTED
'
assert_token "explicit port exact match accepted" "TOK_PORTED" ported git.example:8443
assert_token "portless repo host rejects :8443 login" "" ported git.example
assert_token "wrong explicit port rejected" "" ported git.example:9443
# 7. An implicit (portless) login URL equals the scheme's explicit default port.
write_fixture 'logins:
- name: defported
url: https://git.example
token: TOK_DEFPORT
'
assert_token "implicit https vs explicit :443 match" "TOK_DEFPORT" defported git.example:443
assert_token "implicit https vs :8443 rejected" "" defported git.example:8443
echo "Gitea login resolution regression harness passed" echo "Gitea login resolution regression harness passed"

View File

@@ -1,529 +0,0 @@
#!/usr/bin/env bash
# Regression harness for issue-comment.sh's Gitea comment write + verification
# (#865).
#
# The #865 defect class: tea 0.11.1's `tea issue comment ...` (a nonexistent
# subcommand) silently no-ops yet exits 0, and tea cannot emit the id of a
# record it created — so an exit code is worthless as proof of a durable write.
# The wrapper therefore does NOT write via tea at all. It POSTs the comment to
# the Gitea REST API (which returns the created comment object, including its
# id), then GETs THAT EXACT id back and requires it to match on id, author
# (acting identity), body, and issue. Because verification is keyed to the id
# the create returned, no concurrent comment can masquerade as this write, and a
# suppressed/no-op create yields no id and fails closed.
#
# This harness models a REAL server: the curl stub keeps persistent comment
# state on disk, the POST actually CREATES and PERSISTS a record and returns its
# id, and the read-back GET reads that same state. There is no independently
# fabricated record for the wrapper to "find" — the only way verification
# passes is if the POST genuinely created the record the read-back retrieves.
# It proves the wrapper:
# 1. never shells out to tea to write (no `tea comment` / `tea issue comment`);
# 2. creates the comment via REST POST and learns the provider-returned id;
# 3. verifies THAT EXACT id by direct GET, attributed to the acting identity;
# 4. fails closed when the write is a no-op even though a concurrent
# SAME-IDENTITY comment with the same body already exists (the closed
# concurrency window — no fallback list scan can rescue a no-op);
# 5. fails closed when the created record is not authored by the acting
# identity;
# 6. treats the exact-id GET as the SOLE authority — it performs NO follow-up
# list enumeration (the stub exposes no comment-list endpoint, so any
# residual enumeration attempt would fail the run);
# 7. with a RESOLVABLE --login override, performs the write, the /user identity
# lookup, and the read-back ALL under THAT login's token/identity — never
# the host default;
# 8. with an UNRESOLVABLE --login override, FAILS CLOSED (nonzero, no write, no
# success line) instead of silently downgrading to the host default
# identity — the token seam maps each bearer token to the identity it
# authenticates as, so a misattributed write is caught;
# 9. with a --login override whose tea config URL is a DIFFERENT host than the
# repo remote, FAILS CLOSED (host-bound token selection) rather than sending
# that other host's credential cross-host;
# 10. leaves NO temp files behind (POST/GET bodies + metadata) on either the
# success or the failure path — nested function-scoped RETURN traps do not
# clobber each other and every scratch file is removed on all exit paths.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/issue-comment-readback}"
REPO_DIR="$WORK_DIR/repo"
BIN_DIR="$WORK_DIR/bin"
XDG_DIR="$WORK_DIR/xdg"
TEA_LOG="$WORK_DIR/tea.log"
CURL_LOG="$WORK_DIR/curl.log"
AUTH_LOG="$WORK_DIR/auth.log"
OUTPUT_FILE="$WORK_DIR/output.log"
CREDENTIALS_FILE="$WORK_DIR/credentials.json"
STATE_FILE="$WORK_DIR/comments.json"
# A dedicated scratch dir the wrapper is pointed at via TMPDIR, so the leak
# check can assert every POST/GET body + metadata temp file is cleaned up.
TMP_SCRATCH="$WORK_DIR/scratch"
cleanup() {
rm -rf "$WORK_DIR"
}
trap cleanup EXIT
mkdir -p "$REPO_DIR" "$BIN_DIR" "$XDG_DIR" "$TMP_SCRATCH"
git -C "$REPO_DIR" init -q
git -C "$REPO_DIR" remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git
ISSUE_NUMBER=7
REPO_SLUG="mosaicstack/stack"
API_BASE="https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack"
API_ROOT="https://git.mosaicstack.dev/api/v1"
BODY='durable "note" -- marker'
ACTING_LOGIN="primary-reviewer"
FOREIGN_LOGIN="other-writer"
# A dedicated per-role --login override identity, with its own token stored in
# tea's config (exactly the author-not-equal-reviewer hardening path).
OVERRIDE_LOGIN="delegated-reviewer"
DEFAULT_TOKEN="test-only-placeholder"
OVERRIDE_TOKEN="override-token-placeholder"
# A --login override whose tea config URL points at a DIFFERENT Gitea host than
# the repo remote (git.mosaicstack.dev). Its token must NEVER be sent to the
# repo host: host-bound selection must fail closed on the host mismatch.
CROSS_HOST_LOGIN="foreign-host-reviewer"
CROSS_HOST_TOKEN="cross-host-token-placeholder"
# tea config: the override login has its own token here (as tea itself stores
# per-login tokens). The default login name ("mosaicstack") is deliberately NOT
# present, so the no-override default path resolves via the host credential
# fallback while an explicit --login must resolve from this file or fail closed.
# A second login is configured for a DIFFERENT host to exercise host-bound
# rejection.
mkdir -p "$XDG_DIR/tea"
OVERRIDE_LOGIN="$OVERRIDE_LOGIN" OVERRIDE_TOKEN="$OVERRIDE_TOKEN" \
CROSS_HOST_LOGIN="$CROSS_HOST_LOGIN" CROSS_HOST_TOKEN="$CROSS_HOST_TOKEN" \
python3 - "$XDG_DIR/tea/config.yml" <<'PY'
import os
import sys
with open(sys.argv[1], "w", encoding="utf-8") as handle:
handle.write("logins:\n")
handle.write(f" - name: {os.environ['OVERRIDE_LOGIN']}\n")
handle.write(" url: https://git.mosaicstack.dev\n")
handle.write(f" token: {os.environ['OVERRIDE_TOKEN']}\n")
handle.write(f" - name: {os.environ['CROSS_HOST_LOGIN']}\n")
handle.write(" url: https://git.uscllc.com\n")
handle.write(f" token: {os.environ['CROSS_HOST_TOKEN']}\n")
PY
CONFIGURED_GITEA_URL="https://git.mosaicstack.dev" python3 - "$CREDENTIALS_FILE" <<'PY'
import json
import os
import sys
with open(sys.argv[1], "w", encoding="utf-8") as credentials:
json.dump({
"gitea": {
"mosaicstack": {
"url": os.environ["CONFIGURED_GITEA_URL"],
"token": "test-only-placeholder",
}
}
}, credentials)
PY
# tea stub: only ever answers the login list (used to resolve the default login
# name). It must NEVER be asked to write a comment — the wrapper writes via REST.
cat > "$BIN_DIR/tea" <<'SH'
#!/usr/bin/env bash
set -euo pipefail
printf '%s\n' "$*" >> "$ISSUE_COMMENT_TEA_LOG"
if [[ "$*" == "login list --output json" ]]; then
printf '%s\n' '[{"name":"mosaicstack","url":"https://git.mosaicstack.dev"}]'
exit 0
fi
echo "Unexpected tea command (wrapper must not write via tea): $*" >&2
exit 92
SH
chmod +x "$BIN_DIR/tea"
# curl stub: a small REST server backed by persistent on-disk comment state.
# GET /user -> acting identity
# POST /issues/7/comments -> CREATE + PERSIST, return created object
# GET /issues/comments/{id} -> read the persisted record by exact id
# There is deliberately NO comment-LIST endpoint: exact-id read-back is the sole
# authority, so any residual list enumeration attempt hits the unexpected-request
# guard and fails the test.
cat > "$BIN_DIR/curl" <<'SH'
#!/usr/bin/env bash
set -euo pipefail
output_file=""
method="GET"
url=""
data=""
auth_token=""
while [[ $# -gt 0 ]]; do
case "$1" in
-o) output_file="$2"; shift 2 ;;
-H)
[[ "$2" == Authorization:* ]] && auth_token="${2##* }"
shift 2 ;;
-w) shift 2 ;;
-X) method="$2"; shift 2 ;;
-d|--data) data="$2"; shift 2 ;;
-s|-S|-sS) shift ;;
http://*|https://*) url="$1"; shift ;;
*) shift ;;
esac
done
path="${url%%\?*}"
query="${url#*\?}"
[[ "$query" == "$url" ]] && query=""
printf '%s %s\n' "$method" "$url" >> "$ISSUE_COMMENT_CURL_LOG"
# Map the presented bearer token to the identity it authenticates as — the same
# derivation Gitea's own /user does. The wrapper's write, /user lookup, and
# read-back must all carry the SAME token, so the acting identity recorded here
# reveals which credential actually performed the request.
acting_identity=""
case "$auth_token" in
"$ISSUE_COMMENT_DEFAULT_TOKEN") acting_identity="$ISSUE_COMMENT_ACTING_LOGIN" ;;
"$ISSUE_COMMENT_OVERRIDE_TOKEN") acting_identity="$ISSUE_COMMENT_OVERRIDE_LOGIN" ;;
"$ISSUE_COMMENT_CROSS_HOST_TOKEN") acting_identity="$ISSUE_COMMENT_CROSS_HOST_LOGIN" ;;
esac
printf '%s %s %s\n' "$method" "$path" "${acting_identity:-<unauthenticated>}" >> "$ISSUE_COMMENT_AUTH_LOG"
write_response() {
local status="$1" body="$2"
[[ -n "$output_file" ]] || exit 96
printf '%s' "$body" > "$output_file"
printf '%s' "$status"
}
if [[ "$method" == "GET" && "$path" == "$ISSUE_COMMENT_API_ROOT/user" ]]; then
[[ -n "$acting_identity" ]] || { write_response 401 '{"message":"unauthenticated"}'; exit 0; }
write_response 200 "$(ISSUE_COMMENT_LOGIN="$acting_identity" python3 - <<'PY'
import json
import os
print(json.dumps({"login": os.environ["ISSUE_COMMENT_LOGIN"]}))
PY
)"
elif [[ "$method" == "POST" && "$path" == "$ISSUE_COMMENT_API_BASE/issues/7/comments" ]]; then
result=$(ISSUE_COMMENT_ACTING_LOGIN="${acting_identity:-$ISSUE_COMMENT_ACTING_LOGIN}" ISSUE_COMMENT_DATA="$data" python3 - <<'PY'
import json
import os
state_path = os.environ["ISSUE_COMMENT_STATE"]
mode = os.environ["ISSUE_COMMENT_TEST_MODE"]
acting = os.environ["ISSUE_COMMENT_ACTING_LOGIN"]
foreign = os.environ["ISSUE_COMMENT_FOREIGN_LOGIN"]
repo = os.environ["ISSUE_COMMENT_REPO_SLUG"]
body = json.loads(os.environ["ISSUE_COMMENT_DATA"]).get("body")
with open(state_path, encoding="utf-8") as handle:
comments = json.load(handle)
# no-op-concurrent: the wrapper's own write is SUPPRESSED (returns 200 with no
# created object) even though a concurrent same-identity comment already exists
# in state. Nothing is persisted; there is no created id to verify.
if mode == "no-op-concurrent":
print("200")
print(json.dumps({}))
raise SystemExit(0)
author = foreign if mode == "author-mismatch" else acting
new_id = (max((c["id"] for c in comments), default=0)) + 1
# REAL Gitea comment shape: issue_url is the WEB (html) path, not an API path,
# and a plain issue comment leaves pull_request_url empty. The URL-injection
# modes persist a record whose id/author/body are all correct but whose
# issue_url is forged, so ONLY the origin+path verification can catch them.
issue_url = f"https://git.mosaicstack.dev/{repo}/issues/7"
if mode == "url-wrong-host":
issue_url = f"https://evil.example/{repo}/issues/7"
elif mode == "url-wrong-owner":
issue_url = "https://git.mosaicstack.dev/attacker/stack/issues/7"
elif mode == "url-wrong-repo":
issue_url = "https://git.mosaicstack.dev/mosaicstack/other/issues/7"
elif mode == "url-suffix-injection":
# Prefix-injected: a bare endswith("/<slug>/issues/7") test would ACCEPT this.
issue_url = f"https://git.mosaicstack.dev/deceptive/{repo}/issues/7"
record = {
"id": new_id,
"body": body,
"user": {"login": author},
"issue_url": issue_url,
"pull_request_url": "",
}
comments.append(record)
with open(state_path, "w", encoding="utf-8") as handle:
json.dump(comments, handle)
print("201")
print(json.dumps(record))
PY
)
write_response "$(printf '%s' "$result" | head -n1)" "$(printf '%s' "$result" | tail -n +2)"
elif [[ "$method" == "GET" && "$path" == "$ISSUE_COMMENT_API_BASE"/issues/comments/* ]]; then
result=$(ISSUE_COMMENT_GET_ID="${path##*/}" python3 - <<'PY'
import json
import os
state_path = os.environ["ISSUE_COMMENT_STATE"]
wanted = int(os.environ["ISSUE_COMMENT_GET_ID"])
with open(state_path, encoding="utf-8") as handle:
comments = json.load(handle)
match = next((c for c in comments if c["id"] == wanted), None)
if match is None:
print("404")
print(json.dumps({"message": "not found"}))
else:
print("200")
print(json.dumps(match))
PY
)
write_response "$(printf '%s' "$result" | head -n1)" "$(printf '%s' "$result" | tail -n +2)"
else
echo "Unexpected curl request: $method $url" >&2
exit 97
fi
SH
chmod +x "$BIN_DIR/curl"
# Seed persistent server state for a mode, then run the wrapper against it.
seed_state() {
local mode="$1"
ISSUE_COMMENT_SEED_MODE="$mode" ISSUE_COMMENT_SEED_BODY="$BODY" \
ISSUE_COMMENT_SEED_ACTING="$ACTING_LOGIN" ISSUE_COMMENT_SEED_REPO="$REPO_SLUG" \
python3 - "$STATE_FILE" <<'PY'
import json
import os
import sys
mode = os.environ["ISSUE_COMMENT_SEED_MODE"]
body = os.environ["ISSUE_COMMENT_SEED_BODY"]
acting = os.environ["ISSUE_COMMENT_SEED_ACTING"]
repo = os.environ["ISSUE_COMMENT_SEED_REPO"]
# REAL Gitea comment shape: issue_url is the WEB path, pull_request_url empty.
issue_url = f"https://git.mosaicstack.dev/{repo}/issues/7"
def comment(cid, text, author):
return {
"id": cid,
"body": text,
"user": {"login": author},
"issue_url": issue_url,
"pull_request_url": "",
}
if mode == "fresh-success":
# 50 pre-existing comments already exist; the comment this run creates
# becomes id 51, proving exact-id read-back works regardless of how many
# comments precede it (no list enumeration is involved).
comments = [comment(i, f"prior {i}", acting) for i in range(1, 51)]
elif mode == "no-op-concurrent":
# A concurrent SAME-IDENTITY comment with the IDENTICAL body already exists.
# The wrapper's own write will be a no-op; it must still fail closed because
# no created id is returned — it must not scan and accept this record.
comments = [comment(55, body, acting)]
else: # author-mismatch
comments = []
with open(sys.argv[1], "w", encoding="utf-8") as handle:
json.dump(comments, handle)
PY
}
run_comment() {
local mode="$1"
shift
: > "$TEA_LOG"
: > "$CURL_LOG"
: > "$AUTH_LOG"
: > "$OUTPUT_FILE"
seed_state "$mode"
(
cd "$REPO_DIR"
PATH="$BIN_DIR:$PATH" \
TMPDIR="$TMP_SCRATCH" \
XDG_CONFIG_HOME="$XDG_DIR" \
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
ISSUE_COMMENT_TEA_LOG="$TEA_LOG" \
ISSUE_COMMENT_CURL_LOG="$CURL_LOG" \
ISSUE_COMMENT_AUTH_LOG="$AUTH_LOG" \
ISSUE_COMMENT_STATE="$STATE_FILE" \
ISSUE_COMMENT_TEST_MODE="$mode" \
ISSUE_COMMENT_ACTING_LOGIN="$ACTING_LOGIN" \
ISSUE_COMMENT_FOREIGN_LOGIN="$FOREIGN_LOGIN" \
ISSUE_COMMENT_OVERRIDE_LOGIN="$OVERRIDE_LOGIN" \
ISSUE_COMMENT_CROSS_HOST_LOGIN="$CROSS_HOST_LOGIN" \
ISSUE_COMMENT_DEFAULT_TOKEN="$DEFAULT_TOKEN" \
ISSUE_COMMENT_OVERRIDE_TOKEN="$OVERRIDE_TOKEN" \
ISSUE_COMMENT_CROSS_HOST_TOKEN="$CROSS_HOST_TOKEN" \
ISSUE_COMMENT_REPO_SLUG="$REPO_SLUG" \
ISSUE_COMMENT_API_BASE="$API_BASE" \
ISSUE_COMMENT_API_ROOT="$API_ROOT" \
"$SCRIPT_DIR/issue-comment.sh" -i "$ISSUE_NUMBER" -c "$BODY" "$@"
) > "$OUTPUT_FILE" 2>&1
}
# Assert the wrapper left no scratch temp files behind in TMPDIR (POST/GET
# request bodies + metadata). Called after both success and failure paths so a
# clobbered/leaked RETURN trap is caught on every exit route.
assert_no_temp_leak() {
local context="$1" leaked
leaked=$(find "$TMP_SCRATCH" -type f -name 'mosaic-issue-comment-*' 2>/dev/null || true)
if [[ -n "$leaked" ]]; then
echo "FAIL: issue-comment temp files leaked ($context):" >&2
printf '%s\n' "$leaked" >&2
exit 1
fi
}
# Case 1: a genuine REST create (id 51) is verified end to end via its exact
# provider-returned id — no list enumeration is involved.
run_comment fresh-success
grep -q 'Added and verified comment on Gitea issue #7 (comment ID 51)' "$OUTPUT_FILE"
# The write is a REST POST, never a tea comment.
grep -q "^POST $API_BASE/issues/7/comments$" "$CURL_LOG"
if grep -Eq '^comment |^issue comment ' "$TEA_LOG"; then
echo "FAIL: wrapper wrote a comment via tea instead of REST" >&2
exit 1
fi
# Read-back is a DIRECT GET of the exact created id.
grep -q "^GET $API_BASE/issues/comments/51$" "$CURL_LOG"
# Acting identity resolved via GET /user.
grep -q "^GET $API_ROOT/user$" "$CURL_LOG"
# No comment-list enumeration is performed — the exact-id GET is authoritative.
if grep -Eq "^GET $API_BASE/issues/7/comments(\?|$)" "$CURL_LOG"; then
echo "FAIL: wrapper performed a redundant comment-list enumeration" >&2
exit 1
fi
# Default path (no --login): the host credential fallback resolves, and the
# write is performed AND self-verified under the host-default acting identity.
grep -q "^POST $API_BASE/issues/7/comments $ACTING_LOGIN$" "$AUTH_LOG"
grep -q "^GET $API_BASE/issues/comments/51 $ACTING_LOGIN$" "$AUTH_LOG"
# Success path leaves no scratch temp files behind.
assert_no_temp_leak "fresh-success"
# Case 2: a no-op write with a concurrent SAME-IDENTITY, same-body comment
# already present must FAIL CLOSED — the closed concurrency window.
if run_comment no-op-concurrent; then
echo "FAIL: wrapper reported success when its write no-opped but a concurrent same-identity comment existed" >&2
cat "$OUTPUT_FILE" >&2
exit 1
fi
if grep -q 'Added and verified comment' "$OUTPUT_FILE"; then
echo "FAIL: wrapper accepted a concurrent record for a no-op write (window not closed)" >&2
exit 1
fi
# It must NOT have fallen back to a list scan that could find the concurrent id.
if grep -q "^GET $API_BASE/issues/comments/55$" "$CURL_LOG"; then
echo "FAIL: wrapper read back the concurrent comment id 55 (illegitimate fallback)" >&2
exit 1
fi
# Case 3: a created record NOT authored by the acting identity must FAIL CLOSED.
if run_comment author-mismatch; then
echo "FAIL: wrapper accepted a created comment authored by a different identity" >&2
cat "$OUTPUT_FILE" >&2
exit 1
fi
if grep -q 'Added and verified comment' "$OUTPUT_FILE"; then
echo "FAIL: read-back did not enforce acting-identity authorship" >&2
exit 1
fi
# Failure-after-read-back path must ALSO leave no scratch temp files behind
# (proves the RETURN traps clean up on the error-return route, not just success).
assert_no_temp_leak "author-mismatch"
# Case 4: a RESOLVABLE --login override — the write, the /user identity lookup,
# and the read-back must ALL be performed under THAT login's token/identity, not
# the host default. The override login has id 1 (empty seed).
run_comment override-success --login "$OVERRIDE_LOGIN"
grep -q 'Added and verified comment on Gitea issue #7 (comment ID 1)' "$OUTPUT_FILE"
grep -q "^GET $API_ROOT/user $OVERRIDE_LOGIN$" "$AUTH_LOG"
grep -q "^POST $API_BASE/issues/7/comments $OVERRIDE_LOGIN$" "$AUTH_LOG"
grep -q "^GET $API_BASE/issues/comments/1 $OVERRIDE_LOGIN$" "$AUTH_LOG"
# The host-default identity must NOT have performed ANY request in this run.
if grep -q " $ACTING_LOGIN\$" "$AUTH_LOG"; then
echo "FAIL: an explicit --login override request was performed under the host default identity" >&2
cat "$AUTH_LOG" >&2
exit 1
fi
# Case 5: an UNRESOLVABLE --login override (name absent from tea config) must
# FAIL CLOSED — no silent downgrade to the host default identity: nonzero exit,
# no success line, and NO write performed.
if run_comment override-unresolvable --login "nonexistent-typo-login"; then
echo "FAIL: unresolvable --login override did not fail closed" >&2
cat "$OUTPUT_FILE" >&2
exit 1
fi
if grep -q 'Added and verified comment' "$OUTPUT_FILE"; then
echo "FAIL: unresolvable --login override reported success" >&2
exit 1
fi
if grep -q "^POST $API_BASE/issues/7/comments" "$CURL_LOG"; then
echo "FAIL: unresolvable --login override still performed a write" >&2
exit 1
fi
# And it must not have silently fallen back to the host default identity.
if grep -q " $ACTING_LOGIN\$" "$AUTH_LOG"; then
echo "FAIL: unresolvable --login override fell back to the host default identity" >&2
exit 1
fi
# Case 6: a --login override that IS present in tea config but whose URL is a
# DIFFERENT host than the repo remote must FAIL CLOSED (host-bound selection).
# The cross-host token must NEVER be sent to the repo host, and no write occurs.
if run_comment cross-host --login "$CROSS_HOST_LOGIN"; then
echo "FAIL: cross-host --login override did not fail closed" >&2
cat "$OUTPUT_FILE" >&2
exit 1
fi
if grep -q 'Added and verified comment' "$OUTPUT_FILE"; then
echo "FAIL: cross-host --login override reported success" >&2
exit 1
fi
# The cross-host credential must not have performed ANY request against the repo
# host — no request may be attributed to the cross-host identity.
if grep -q " $CROSS_HOST_LOGIN\$" "$AUTH_LOG"; then
echo "FAIL: cross-host credential was sent to the repo host (cross-host leak)" >&2
cat "$AUTH_LOG" >&2
exit 1
fi
if grep -q "^POST $API_BASE/issues/7/comments" "$CURL_LOG"; then
echo "FAIL: cross-host --login override still performed a write" >&2
exit 1
fi
# It must not have silently downgraded to the host default identity either.
if grep -q " $ACTING_LOGIN\$" "$AUTH_LOG"; then
echo "FAIL: cross-host --login override fell back to the host default identity" >&2
exit 1
fi
assert_no_temp_leak "cross-host"
# Cases 7-10 (#865 Blocker 3): the created record's id/author/body are all
# correct, but its provider-returned issue_url is forged. Verification pins the
# URL's ORIGIN (scheme+host+effective-port) and its FULL path (deployment prefix
# + exact owner/repo + kind + number), so each forgery must FAIL CLOSED. A bare
# endswith/suffix test would wrongly accept the look-alike-host and
# prefix-injection variants.
for bad_mode in url-wrong-host url-wrong-owner url-wrong-repo url-suffix-injection; do
if run_comment "$bad_mode"; then
echo "FAIL: forged comment URL ($bad_mode) was accepted" >&2
cat "$OUTPUT_FILE" >&2
exit 1
fi
if grep -q 'Added and verified comment' "$OUTPUT_FILE"; then
echo "FAIL: forged comment URL ($bad_mode) passed verification" >&2
exit 1
fi
assert_no_temp_leak "$bad_mode"
done
# Sanity: the exact same verification path still ACCEPTS a legitimate web-shaped
# issue_url (already exercised by Case 1's fresh-success), so the tightened check
# is not rejecting genuine writes.
echo "issue-comment.sh REST create + exact-id read-back regression passed"

View File

@@ -1,38 +1,5 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Regression harness for pr-review.sh's Gitea review + comment writes (#865, # Regression harness for durable Gitea PR review comments (#812).
# #812, #835).
#
# The #865 defect class: tea 0.11.1 can silently no-op while exiting 0 and
# cannot emit the id of a record it creates, so its exit code is worthless as
# proof of a durable write. The wrapper therefore does NOT write reviews or
# comments via tea. approve/request-changes POST to /pulls/{n}/reviews (with the
# event, the PR head commit_id, and the review body) and read the created review
# back by its EXACT provider-returned id; the `comment` action POSTs to
# /issues/{n}/comments and reads that created comment back by its exact id.
# Because verification keys on the id the create returned, no concurrent record
# can masquerade as this write and a no-op create fails closed. tea is only ever
# consulted for the login list.
#
# The curl stub models a REAL server with persistent review/comment state on
# disk: a POST actually CREATES and PERSISTS a record and returns its id, and
# the read-back reads that same state. There is no independently fabricated
# record for the wrapper to "find" — verification passes only when the POST
# genuinely created the record the read-back retrieves.
#
# #865 Round-4: the curl stub also maps the presented bearer token to the
# identity it authenticates as and logs it per request, so tests can prove
# credential attribution. An explicit --login override must drive the entire
# write→read-back chain under THAT login's token (resolvable case) or FAIL
# CLOSED (unresolvable case) — never silently downgrade to the host-default
# identity. The host-default best-effort fallback is reserved for the
# no-override default path.
#
# #865 Round-5: the exact-id read-back is the SOLE authority — the wrapper does
# NO follow-up list enumeration (the stub exposes no review/comment list
# endpoint, so a residual enumeration would fail the run). A --login override is
# host-bound: an override configured for a DIFFERENT host than the repo remote
# FAILS CLOSED rather than leaking a cross-host credential. And every run leaves
# no scratch temp files behind on any exit path (POST/GET bodies + metadata).
set -euo pipefail set -euo pipefail
@@ -40,65 +7,20 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-review-gitea-comment}" WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-review-gitea-comment}"
REPO_DIR="$WORK_DIR/repo" REPO_DIR="$WORK_DIR/repo"
BIN_DIR="$WORK_DIR/bin" BIN_DIR="$WORK_DIR/bin"
XDG_DIR="$WORK_DIR/xdg"
STATE_DIR="$WORK_DIR/state"
REVIEWS_FILE="$STATE_DIR/reviews.json"
COMMENTS_FILE="$STATE_DIR/comments.json"
SUBMIT_PAYLOAD_FILE="$STATE_DIR/review_payload.json"
TEA_LOG="$WORK_DIR/tea.log" TEA_LOG="$WORK_DIR/tea.log"
CURL_LOG="$WORK_DIR/curl.log" CURL_LOG="$WORK_DIR/curl.log"
AUTH_LOG="$WORK_DIR/auth.log"
OUTPUT_FILE="$WORK_DIR/output.log" OUTPUT_FILE="$WORK_DIR/output.log"
CREDENTIALS_FILE="$WORK_DIR/credentials.json" CREDENTIALS_FILE="$WORK_DIR/credentials.json"
# A dedicated scratch dir the wrapper is pointed at via TMPDIR, so the leak
# check can assert every POST/GET body + metadata temp file is cleaned up.
TMP_SCRATCH="$WORK_DIR/scratch"
cleanup() { cleanup() {
rm -rf "$WORK_DIR" rm -rf "$WORK_DIR"
} }
trap cleanup EXIT trap cleanup EXIT
ACTING_LOGIN="review-bot" mkdir -p "$REPO_DIR" "$BIN_DIR"
FOREIGN_LOGIN="other-writer"
HEAD_SHA="HEADSHA_FEEDFACE"
# A dedicated per-role --login override identity with its own token in tea's
# config (the author-not-equal-reviewer hardening path).
OVERRIDE_LOGIN="primary-reviewer"
DEFAULT_TOKEN="test-only-placeholder"
OVERRIDE_TOKEN="override-token-placeholder"
# A --login override whose tea config URL points at a DIFFERENT Gitea host than
# the repo remote (git.mosaicstack.dev). Host-bound selection must reject it
# rather than send its token cross-host.
CROSS_HOST_LOGIN="foreign-host-reviewer"
CROSS_HOST_TOKEN="cross-host-token-placeholder"
mkdir -p "$REPO_DIR" "$BIN_DIR" "$XDG_DIR" "$STATE_DIR" "$TMP_SCRATCH"
git -C "$REPO_DIR" init -q git -C "$REPO_DIR" init -q
git -C "$REPO_DIR" remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git git -C "$REPO_DIR" remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git
# tea config: the override login carries its own token here. The default login
# name ("mosaicstack") is deliberately absent, so the no-override default path
# resolves via the host credential fallback while an explicit --login must
# resolve from this file or fail closed. A second login is configured for a
# DIFFERENT host to exercise host-bound rejection.
mkdir -p "$XDG_DIR/tea"
OVERRIDE_LOGIN="$OVERRIDE_LOGIN" OVERRIDE_TOKEN="$OVERRIDE_TOKEN" \
CROSS_HOST_LOGIN="$CROSS_HOST_LOGIN" CROSS_HOST_TOKEN="$CROSS_HOST_TOKEN" \
python3 - "$XDG_DIR/tea/config.yml" <<'PY'
import os
import sys
with open(sys.argv[1], "w", encoding="utf-8") as handle:
handle.write("logins:\n")
handle.write(f" - name: {os.environ['OVERRIDE_LOGIN']}\n")
handle.write(" url: https://git.mosaicstack.dev\n")
handle.write(f" token: {os.environ['OVERRIDE_TOKEN']}\n")
handle.write(f" - name: {os.environ['CROSS_HOST_LOGIN']}\n")
handle.write(" url: https://git.uscllc.com\n")
handle.write(f" token: {os.environ['CROSS_HOST_TOKEN']}\n")
PY
write_credentials() { write_credentials() {
local configured_url="$1" local configured_url="$1"
CONFIGURED_GITEA_URL="$configured_url" python3 - "$CREDENTIALS_FILE" <<'PY' CONFIGURED_GITEA_URL="$configured_url" python3 - "$CREDENTIALS_FILE" <<'PY'
@@ -118,9 +40,6 @@ with open(sys.argv[1], "w", encoding="utf-8") as credentials:
PY PY
} }
# tea stub: only ever answers the login list. The wrapper must never write a
# review or comment through tea (#865 defect class); any other tea invocation is
# an error.
cat > "$BIN_DIR/tea" <<'SH' cat > "$BIN_DIR/tea" <<'SH'
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
@@ -128,17 +47,33 @@ set -euo pipefail
printf '%s\n' "$*" >> "$PR_REVIEW_TEA_LOG" printf '%s\n' "$*" >> "$PR_REVIEW_TEA_LOG"
if [[ "$*" == "login list --output json" ]]; then if [[ "$*" == "login list --output json" ]]; then
printf '[{"name":"mosaicstack","url":"%s"}]\n' "$PR_REVIEW_LOGIN_URL" printf '%s\n' '[{"name":"mosaicstack","url":"https://git.mosaicstack.dev"}]'
exit 0 exit 0
fi fi
echo "Unexpected tea command (wrapper must not write via tea): $*" >&2 case "${PR_REVIEW_TEST_MODE:-}" in
approve)
[[ "$*" == "pr approve 123 --repo mosaicstack/stack --login mosaicstack" ]] || exit 90
;;
request-changes)
[[ "$*" == "pr reject 123 --repo mosaicstack/stack --login mosaicstack --comment changes-required" ]] || exit 91
;;
legacy-fallback|comment-success|http-success|prefix-success|subpath-success|port-success|scp-ssh-success|url-ssh-success|write-transport-failure|write-http-failure|readback-failure)
if [[ "$*" == pr\ comment* ]]; then
# tea v0.11.1 treats the nonexistent subcommand as `tea pr list` and exits 0.
printf '%s\n' 'INDEX TITLE STATE'
exit 0
fi
echo "Unexpected tea command: $*" >&2
exit 92 exit 92
;;
*)
exit 95
;;
esac
SH SH
chmod +x "$BIN_DIR/tea" chmod +x "$BIN_DIR/tea"
# curl stub: a small REST server backed by persistent on-disk review/comment
# state.
cat > "$BIN_DIR/curl" <<'SH' cat > "$BIN_DIR/curl" <<'SH'
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
@@ -147,39 +82,38 @@ output_file=""
method="GET" method="GET"
payload="" payload=""
url="" url=""
auth_token=""
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
case "$1" in case "$1" in
-o) output_file="$2"; shift 2 ;; -o)
-H) output_file="$2"
[[ "$2" == Authorization:* ]] && auth_token="${2##* }" shift 2
shift 2 ;; ;;
-w) shift 2 ;; -w|-H)
-X) method="$2"; shift 2 ;; shift 2
-d|--data) payload="$2"; shift 2 ;; ;;
-s|-S|-sS) shift ;; -X)
http://*|https://*) url="$1"; shift ;; method="$2"
*) shift ;; shift 2
;;
-d|--data)
payload="$2"
shift 2
;;
-s|-S|-sS)
shift
;;
http://*|https://*)
url="$1"
shift
;;
*)
shift
;;
esac esac
done done
path="${url%%\?*}"
query="${url#*\?}"
[[ "$query" == "$url" ]] && query=""
printf '%s %s\n' "$method" "$url" >> "$PR_REVIEW_CURL_LOG" printf '%s %s\n' "$method" "$url" >> "$PR_REVIEW_CURL_LOG"
# Map the presented bearer token to the identity it authenticates as (as Gitea's
# /user does). The write, /user lookup, and read-back must all carry the SAME
# token, so the identity logged here reveals which credential performed each
# request — proving an explicit --login override is honored, not downgraded.
acting_identity=""
case "$auth_token" in
"$PR_REVIEW_DEFAULT_TOKEN") acting_identity="$PR_REVIEW_ACTING_LOGIN" ;;
"$PR_REVIEW_OVERRIDE_TOKEN") acting_identity="$PR_REVIEW_OVERRIDE_LOGIN" ;;
"$PR_REVIEW_CROSS_HOST_TOKEN") acting_identity="$PR_REVIEW_CROSS_HOST_LOGIN" ;;
esac
printf '%s %s %s\n' "$method" "$path" "${acting_identity:-<unauthenticated>}" >> "$PR_REVIEW_AUTH_LOG"
write_response() { write_response() {
local status="$1" body="$2" local status="$1" body="$2"
[[ -n "$output_file" ]] || exit 96 [[ -n "$output_file" ]] || exit 96
@@ -187,420 +121,116 @@ write_response() {
printf '%s' "$status" printf '%s' "$status"
} }
emit() { case "${PR_REVIEW_TEST_MODE:-}" in
# Split a two-line "status\n<json body>" python result into the response. legacy-fallback|write-transport-failure)
local result="$1"
write_response "$(printf '%s' "$result" | head -n1)" "$(printf '%s' "$result" | tail -n +2)"
}
mode="${PR_REVIEW_TEST_MODE:-}"
if [[ "$method" == "GET" && "$path" == "$PR_REVIEW_API_ROOT/user" ]]; then
[[ -n "$acting_identity" ]] || { write_response 401 '{"message":"unauthenticated"}'; exit 0; }
write_response 200 "$(PR_REVIEW_LOGIN="$acting_identity" python3 - <<'PY'
import json
import os
print(json.dumps({"login": os.environ["PR_REVIEW_LOGIN"]}))
PY
)"
elif [[ "$method" == "GET" && "$path" == "$PR_REVIEW_EXPECTED_API_BASE/pulls/123" ]]; then
write_response 200 "$(PR_REVIEW_HEAD_SHA="$PR_REVIEW_HEAD_SHA" python3 - <<'PY'
import json
import os
print(json.dumps({"head": {"sha": os.environ["PR_REVIEW_HEAD_SHA"]}}))
PY
)"
elif [[ "$method" == "POST" && "$path" == "$PR_REVIEW_EXPECTED_API_BASE/pulls/123/reviews" ]]; then
printf '%s' "$payload" > "$PR_REVIEW_SUBMIT_PAYLOAD"
emit "$(PR_REVIEW_ACTING_LOGIN="${acting_identity:-$PR_REVIEW_ACTING_LOGIN}" PR_REVIEW_PAYLOAD="$payload" python3 - <<'PY'
import json
import os
state_path = os.environ["PR_REVIEW_REVIEWS"]
mode = os.environ["PR_REVIEW_TEST_MODE"]
acting = os.environ["PR_REVIEW_ACTING_LOGIN"]
foreign = os.environ["PR_REVIEW_FOREIGN_LOGIN"]
submitted = json.loads(os.environ["PR_REVIEW_PAYLOAD"])
with open(state_path, encoding="utf-8") as handle:
reviews = json.load(handle)
# no-op-concurrent-review: the wrapper's own submit is SUPPRESSED (200, no
# created object) even though a concurrent same-identity, same-state review at
# the same head already exists. Nothing is persisted; no created id to verify.
if mode == "no-op-concurrent-review":
print("200")
print(json.dumps({}))
raise SystemExit(0)
# review-body-reuse (#865 Blocker 4): Gitea v1.25.4's SubmitReview can finalize
# and REUSE a pending review id whose Content was authored earlier — NOT this
# submit's body. id/author/state/head all line up with the request; only the
# persisted body diverges, so only body verification catches it. The read-back
# GET returns this same divergent-body record.
if mode == "review-body-reuse":
new_id = (max((r["id"] for r in reviews), default=0)) + 1
record = {
"id": new_id,
"state": submitted.get("event"),
"commit_id": submitted.get("commit_id"),
"body": "leftover-pending-content-not-this-submit",
"user": {"login": acting},
}
reviews.append(record)
with open(state_path, "w", encoding="utf-8") as handle:
json.dump(reviews, handle)
print("201")
print(json.dumps(record))
raise SystemExit(0)
author = foreign if mode == "author-mismatch-review" else acting
new_id = (max((r["id"] for r in reviews), default=0)) + 1
record = {
"id": new_id,
"state": submitted.get("event"),
"commit_id": submitted.get("commit_id"),
"body": submitted.get("body"),
"user": {"login": author},
}
reviews.append(record)
with open(state_path, "w", encoding="utf-8") as handle:
json.dump(reviews, handle)
print("201")
print(json.dumps(record))
PY
)"
elif [[ "$method" == "GET" && "$path" == "$PR_REVIEW_EXPECTED_API_BASE"/pulls/123/reviews/* ]]; then
emit "$(PR_REVIEW_GET_ID="${path##*/}" python3 - <<'PY'
import json
import os
state_path = os.environ["PR_REVIEW_REVIEWS"]
wanted = int(os.environ["PR_REVIEW_GET_ID"])
with open(state_path, encoding="utf-8") as handle:
reviews = json.load(handle)
match = next((r for r in reviews if r["id"] == wanted), None)
if match is None:
print("404")
print(json.dumps({"message": "not found"}))
else:
print("200")
print(json.dumps(match))
PY
)"
elif [[ "$method" == "POST" && "$path" == "$PR_REVIEW_EXPECTED_API_BASE/issues/123/comments" ]]; then
case "$mode" in
write-transport-failure)
echo "simulated transport failure" >&2 echo "simulated transport failure" >&2
exit 7 exit 7
;; ;;
write-http-failure) write-http-failure)
write_response 500 '{"message":"simulated rejection"}' write_response 500 '{"message":"simulated rejection"}'
;; ;;
*) comment-success|http-success|prefix-success|subpath-success|port-success|scp-ssh-success|url-ssh-success|readback-failure)
emit "$(PR_REVIEW_PAYLOAD="$payload" PR_REVIEW_TEST_MODE="$mode" python3 - <<'PY' if [[ "$method" == "POST" && "$url" == "$PR_REVIEW_EXPECTED_API_BASE/issues/123/comments" ]]; then
PR_REVIEW_PAYLOAD="$payload" python3 - <<'PY'
import json import json
import os import os
from urllib.parse import urlparse
state_path = os.environ["PR_REVIEW_COMMENTS"] assert json.loads(os.environ["PR_REVIEW_PAYLOAD"]) == {"body": os.environ["PR_REVIEW_EXPECTED_BODY"]}
acting = os.environ["PR_REVIEW_ACTING_LOGIN"] PY
web_base = os.environ["PR_REVIEW_WEB_BASE"] response=$(python3 - <<'PY'
mode = os.environ.get("PR_REVIEW_TEST_MODE", "") import json
body = json.loads(os.environ["PR_REVIEW_PAYLOAD"]).get("body") import os
# REAL Gitea shape for a comment posted to a PR's conversation
# (/issues/{n}/comments on a PR): pull_request_url is the WEB pulls path and print(json.dumps({"id": 456, "body": os.environ["PR_REVIEW_EXPECTED_BODY"]}))
# issue_url is left empty. This is what the wrapper must tolerate — it must NOT PY
# require an API-shaped issue_url. )
pr_url = f"{web_base}/pulls/123" write_response 201 "$response"
# URL-injection modes (#865 Blocker 3): id/author/body are all correct but the elif [[ "$method" == "GET" && "$url" == "$PR_REVIEW_EXPECTED_API_BASE/issues/comments/456" ]]; then
# provider-returned pull_request_url is forged, so ONLY origin+full-path if [[ "$PR_REVIEW_TEST_MODE" == "readback-failure" ]]; then
# verification can catch them. body="different-body"
_p = urlparse(web_base) else
_origin = f"{_p.scheme}://{_p.netloc}" body="$PR_REVIEW_EXPECTED_BODY"
_slug = _p.path # /<owner>/<repo> fi
if mode == "comment-url-wrong-host": response=$(PR_REVIEW_BODY="$body" python3 - <<'PY'
pr_url = f"https://evil.example{_slug}/pulls/123" import json
elif mode == "comment-url-wrong-owner": import os
pr_url = f"{_origin}/attacker/stack/pulls/123"
elif mode == "comment-url-wrong-repo": print(json.dumps({
pr_url = f"{_origin}/mosaicstack/other/pulls/123"
elif mode == "comment-url-suffix-injection":
# Prefix-injected: a bare endswith("/<slug>/pulls/123") test would ACCEPT it.
pr_url = f"{_origin}/deceptive{_slug}/pulls/123"
record = {
"id": 456, "id": 456,
"body": body, "body": os.environ["PR_REVIEW_BODY"],
"user": {"login": acting}, "issue_url": os.environ["PR_REVIEW_EXPECTED_API_BASE"] + "/issues/123",
"issue_url": "", }))
"pull_request_url": pr_url,
}
with open(state_path, "w", encoding="utf-8") as handle:
json.dump([record], handle)
print("201")
print(json.dumps(record))
PY PY
)" )
;; write_response 200 "$response"
esac
elif [[ "$method" == "GET" && "$path" == "$PR_REVIEW_EXPECTED_API_BASE"/issues/comments/* ]]; then
emit "$(PR_REVIEW_GET_ID="${path##*/}" python3 - <<'PY'
import json
import os
state_path = os.environ["PR_REVIEW_COMMENTS"]
mode = os.environ["PR_REVIEW_TEST_MODE"]
wanted = int(os.environ["PR_REVIEW_GET_ID"])
with open(state_path, encoding="utf-8") as handle:
comments = json.load(handle)
match = next((c for c in comments if c["id"] == wanted), None)
if match is None:
print("404")
print(json.dumps({"message": "not found"}))
raise SystemExit(0)
if mode == "readback-failure":
# The server returns a DIFFERENT body than was created — a genuine
# provider-side mismatch the wrapper must reject.
match = dict(match, body="different-body")
print("200")
print(json.dumps(match))
PY
)"
else else
echo "Unexpected curl request: $method $url" >&2 echo "Unexpected curl request: $method $url" >&2
exit 97 exit 97
fi fi
;;
*)
exit 98
;;
esac
SH SH
chmod +x "$BIN_DIR/curl" chmod +x "$BIN_DIR/curl"
# Seed persistent server state for a mode before the wrapper runs.
seed_state() {
local mode="$1"
printf '[]' > "$COMMENTS_FILE"
rm -f "$SUBMIT_PAYLOAD_FILE"
PR_REVIEW_SEED_MODE="$mode" PR_REVIEW_SEED_ACTING="$ACTING_LOGIN" \
PR_REVIEW_SEED_HEAD="$HEAD_SHA" python3 - "$REVIEWS_FILE" <<'PY'
import json
import os
import sys
mode = os.environ["PR_REVIEW_SEED_MODE"]
acting = os.environ["PR_REVIEW_SEED_ACTING"]
head = os.environ["PR_REVIEW_SEED_HEAD"]
def review(rid, state, commit, login):
return {"id": rid, "state": state, "commit_id": commit, "user": {"login": login}}
if mode == "many-prior-approve":
# 50 pre-existing reviews already exist; the review this run submits becomes
# id 51, proving exact-id read-back works regardless of how many reviews
# precede it (no list enumeration is involved).
reviews = [review(i, "COMMENT", "oldsha0000", acting) for i in range(1, 51)]
elif mode == "no-op-concurrent-review":
# A concurrent SAME-IDENTITY APPROVED review at the CURRENT head already
# exists. The wrapper's own submit will be a no-op; it must fail closed
# because no created id is returned — it must not scan and accept this one.
reviews = [review(77, "APPROVED", head, acting)]
else:
reviews = [review(100, "COMMENT", "oldsha0000", acting)]
with open(sys.argv[1], "w", encoding="utf-8") as handle:
json.dump(reviews, handle)
PY
}
run_review() { run_review() {
local mode="$1" action="$2" comment="${3:-}" local mode="$1" action="$2" comment="${3:-}"
local configured_url="${4:-https://git.mosaicstack.dev}" local configured_url="${4:-https://git.mosaicstack.dev}"
local remote_url="${5:-https://git.mosaicstack.dev/mosaicstack/stack.git}" local remote_url="${5:-https://git.mosaicstack.dev/mosaicstack/stack.git}"
local expected_repo="${6:-mosaicstack/stack}" local expected_repo="${6:-mosaicstack/stack}"
local login_override="${7:-}"
local expected_api_base="${configured_url%/}/api/v1/repos/$expected_repo" local expected_api_base="${configured_url%/}/api/v1/repos/$expected_repo"
local expected_api_root="${configured_url%/}/api/v1"
local expected_web_base="${configured_url%/}/$expected_repo"
git -C "$REPO_DIR" remote set-url origin "$remote_url" git -C "$REPO_DIR" remote set-url origin "$remote_url"
write_credentials "$configured_url" write_credentials "$configured_url"
: > "$TEA_LOG" : > "$TEA_LOG"
: > "$CURL_LOG" : > "$CURL_LOG"
: > "$AUTH_LOG"
: > "$OUTPUT_FILE" : > "$OUTPUT_FILE"
seed_state "$mode"
( (
cd "$REPO_DIR" cd "$REPO_DIR"
PATH="$BIN_DIR:$PATH" \ PATH="$BIN_DIR:$PATH" \
TMPDIR="$TMP_SCRATCH" \
XDG_CONFIG_HOME="$XDG_DIR" \
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \ MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
PR_REVIEW_TEA_LOG="$TEA_LOG" \ PR_REVIEW_TEA_LOG="$TEA_LOG" \
PR_REVIEW_LOGIN_URL="${configured_url%/}" \
PR_REVIEW_CURL_LOG="$CURL_LOG" \ PR_REVIEW_CURL_LOG="$CURL_LOG" \
PR_REVIEW_AUTH_LOG="$AUTH_LOG" \
PR_REVIEW_REVIEWS="$REVIEWS_FILE" \
PR_REVIEW_COMMENTS="$COMMENTS_FILE" \
PR_REVIEW_SUBMIT_PAYLOAD="$SUBMIT_PAYLOAD_FILE" \
PR_REVIEW_TEST_MODE="$mode" \ PR_REVIEW_TEST_MODE="$mode" \
PR_REVIEW_EXPECTED_BODY="$comment" \ PR_REVIEW_EXPECTED_BODY="$comment" \
PR_REVIEW_EXPECTED_API_BASE="$expected_api_base" \ PR_REVIEW_EXPECTED_API_BASE="$expected_api_base" \
PR_REVIEW_API_ROOT="$expected_api_root" \ "$SCRIPT_DIR/pr-review.sh" -n 123 -a "$action" ${comment:+-c "$comment"}
PR_REVIEW_WEB_BASE="$expected_web_base" \
PR_REVIEW_HEAD_SHA="$HEAD_SHA" \
PR_REVIEW_ACTING_LOGIN="$ACTING_LOGIN" \
PR_REVIEW_FOREIGN_LOGIN="$FOREIGN_LOGIN" \
PR_REVIEW_OVERRIDE_LOGIN="$OVERRIDE_LOGIN" \
PR_REVIEW_CROSS_HOST_LOGIN="$CROSS_HOST_LOGIN" \
PR_REVIEW_DEFAULT_TOKEN="$DEFAULT_TOKEN" \
PR_REVIEW_OVERRIDE_TOKEN="$OVERRIDE_TOKEN" \
PR_REVIEW_CROSS_HOST_TOKEN="$CROSS_HOST_TOKEN" \
"$SCRIPT_DIR/pr-review.sh" -n 123 -a "$action" ${comment:+-c "$comment"} ${login_override:+--login "$login_override"}
) > "$OUTPUT_FILE" 2>&1 ) > "$OUTPUT_FILE" 2>&1
} }
# Assert the wrapper left no scratch temp files behind in TMPDIR (POST/GET
# request bodies + metadata). Called after both success and failure paths so a
# clobbered/leaked RETURN trap is caught on every exit route.
assert_no_temp_leak() {
local context="$1" leaked
leaked=$(find "$TMP_SCRATCH" -type f -name 'mosaic-pr-review-*' 2>/dev/null || true)
if [[ -n "$leaked" ]]; then
echo "FAIL: pr-review temp files leaked ($context):" >&2
printf '%s\n' "$leaked" >&2
exit 1
fi
}
assert_no_tea_write() {
# tea must only ever be used for the login list, never to write.
if grep -qvE '^login list --output json$' "$TEA_LOG"; then
echo "FAIL: wrapper invoked tea for something other than the login list" >&2
cat "$TEA_LOG" >&2
exit 1
fi
}
# Case 1: a plain approve submits a review via REST and verifies it by its exact
# provider-returned id (id 101), attributed to the acting identity, pinned to
# the PR head, with no separate comment.
run_review approve approve run_review approve approve
grep -q 'Approved and verified Gitea PR #123 (review ID 101)' "$OUTPUT_FILE" grep -q '^pr approve 123 --repo mosaicstack/stack --login mosaicstack$' "$TEA_LOG"
grep -q '^GET https://git.mosaicstack.dev/api/v1/user$' "$CURL_LOG" grep -q 'Approved Gitea PR #123' "$OUTPUT_FILE"
grep -q '^GET https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/pulls/123$' "$CURL_LOG"
grep -q '^POST https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/pulls/123/reviews$' "$CURL_LOG"
grep -q '^GET https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/pulls/123/reviews/101$' "$CURL_LOG"
# No review-list enumeration is performed — the exact-id GET is authoritative.
if grep -Eq '^GET https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/pulls/123/reviews(\?|$)' "$CURL_LOG"; then
echo "FAIL: wrapper performed a redundant review-list enumeration" >&2
exit 1
fi
# No-override default path: the write, /user lookup, and read-back all resolve
# via the host-default credential and authenticate as the acting identity.
grep -q "^POST https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/pulls/123/reviews $ACTING_LOGIN\$" "$AUTH_LOG"
grep -q "^GET https://git.mosaicstack.dev/api/v1/user $ACTING_LOGIN\$" "$AUTH_LOG"
assert_no_tea_write
assert_no_temp_leak "approve"
# The submitted review payload carries the event and the PR head commit_id.
PR_REVIEW_HEAD_SHA="$HEAD_SHA" python3 - "$SUBMIT_PAYLOAD_FILE" <<'PY'
import json
import os
import sys
payload = json.load(open(sys.argv[1], encoding="utf-8"))
assert payload["event"] == "APPROVED", payload
assert payload["commit_id"] == os.environ["PR_REVIEW_HEAD_SHA"], payload
PY
# A plain approve (no body) must not POST a comment.
if grep -q '/issues/123/comments' "$CURL_LOG"; then
echo "FAIL: plain approve unexpectedly posted a comment" >&2
exit 1
fi
# Case 2: a submitted review NOT authored by the acting identity must FAIL
# CLOSED — the exact-id read-back enforces authorship.
if run_review author-mismatch-review approve; then
echo "FAIL: approve accepted a review authored by a different identity" >&2
cat "$OUTPUT_FILE" >&2
exit 1
fi
if grep -q 'Approved and verified' "$OUTPUT_FILE"; then
echo "FAIL: read-back did not enforce acting-identity authorship" >&2
exit 1
fi
# Failure-after-read-back path must ALSO leave no scratch temp files behind.
assert_no_temp_leak "author-mismatch-review"
# Case 3: a no-op submit with a concurrent SAME-IDENTITY, same-state review at
# the current head already present must FAIL CLOSED — the closed concurrency
# window. The wrapper must not read back (or accept) the concurrent id 77.
if run_review no-op-concurrent-review approve; then
echo "FAIL: approve reported success when its submit no-opped but a concurrent review existed" >&2
cat "$OUTPUT_FILE" >&2
exit 1
fi
if grep -q 'Approved and verified' "$OUTPUT_FILE"; then
echo "FAIL: approve accepted a concurrent review for a no-op submit (window not closed)" >&2
exit 1
fi
if grep -q '/pulls/123/reviews/77$' "$CURL_LOG"; then
echo "FAIL: wrapper read back the concurrent review id 77 (illegitimate fallback)" >&2
exit 1
fi
# Case 4: a genuine matching review (id 51) created after 50 pre-existing reviews
# is still verified by its EXACT provider-returned id — no list enumeration is
# needed regardless of how many reviews precede it.
run_review many-prior-approve approve
grep -q 'Approved and verified Gitea PR #123 (review ID 51)' "$OUTPUT_FILE"
grep -q '^GET https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/pulls/123/reviews/51$' "$CURL_LOG"
if grep -Eq '^GET https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/pulls/123/reviews(\?|$)' "$CURL_LOG"; then
echo "FAIL: wrapper performed a redundant review-list enumeration" >&2
exit 1
fi
# Case 5: an approve WITH a body carries that body in the review submit itself —
# there is no separate detached comment POST.
run_review approve approve approve-note
grep -q 'Approved and verified Gitea PR #123 (review ID 101)' "$OUTPUT_FILE"
PR_REVIEW_EXPECTED_BODY="approve-note" python3 - "$SUBMIT_PAYLOAD_FILE" <<'PY'
import json
import os
import sys
payload = json.load(open(sys.argv[1], encoding="utf-8"))
assert payload["body"] == os.environ["PR_REVIEW_EXPECTED_BODY"], payload
PY
if grep -q '/issues/123/comments' "$CURL_LOG"; then
echo "FAIL: approve-with-body posted a separate comment instead of carrying the body on the review" >&2
exit 1
fi
# Case 6: request-changes requires a body and carries it on the REQUEST_CHANGES
# review submit.
run_review request-changes request-changes changes-required run_review request-changes request-changes changes-required
grep -q 'Requested changes and verified on Gitea PR #123 (review ID 101)' "$OUTPUT_FILE" grep -q '^pr reject 123 --repo mosaicstack/stack --login mosaicstack --comment changes-required$' "$TEA_LOG"
grep -q '^POST https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/pulls/123/reviews$' "$CURL_LOG" grep -q 'Requested changes on Gitea PR #123' "$OUTPUT_FILE"
grep -q '^GET https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/pulls/123/reviews/101$' "$CURL_LOG"
PR_REVIEW_EXPECTED_BODY="changes-required" python3 - "$SUBMIT_PAYLOAD_FILE" <<'PY'
import json
import os
import sys
payload = json.load(open(sys.argv[1], encoding="utf-8")) if run_review legacy-fallback comment durable-body; then
assert payload["event"] == "REQUEST_CHANGES", payload echo "The old nonexistent tea pr comment fallback returned success" >&2
assert payload["body"] == os.environ["PR_REVIEW_EXPECTED_BODY"], payload cat "$OUTPUT_FILE" >&2
PY exit 1
assert_no_tea_write fi
if grep -q '^pr comment ' "$TEA_LOG"; then
echo "Wrapper invoked unsupported tea pr comment" >&2
exit 1
fi
if grep -q 'Added comment to Gitea PR' "$OUTPUT_FILE"; then
echo "Wrapper reported success without durable persistence" >&2
exit 1
fi
# Case 7: the `comment` action creates a comment via REST and verifies it by its
# exact created id, attributed to the acting identity. This also exercises
# owner/repo + base-URL resolution across clone-URL shapes.
complex_body=$'durable "body"\n-- marker' complex_body=$'durable "body"\n-- marker'
run_review comment-success comment "$complex_body" run_review comment-success comment "$complex_body"
grep -q '^POST https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/issues/123/comments$' "$CURL_LOG" grep -q '^POST https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/issues/123/comments$' "$CURL_LOG"
grep -q '^GET https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/issues/comments/456$' "$CURL_LOG" grep -q '^GET https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/issues/comments/456$' "$CURL_LOG"
grep -q 'Added and verified comment on Gitea PR #123' "$OUTPUT_FILE" grep -q 'Added and verified comment on Gitea PR #123' "$OUTPUT_FILE"
assert_no_tea_write if [[ -s "$TEA_LOG" ]]; then
assert_no_temp_leak "comment-success" echo "REST comment path unexpectedly invoked tea" >&2
cat "$TEA_LOG" >&2
exit 1
fi
run_review http-success comment durable-body http://git.mosaicstack.dev run_review http-success comment durable-body http://git.mosaicstack.dev
grep -q '^POST http://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/issues/123/comments$' "$CURL_LOG" grep -q '^POST http://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/issues/123/comments$' "$CURL_LOG"
@@ -628,17 +258,6 @@ grep -q '^POST https://git.example/api/v1/repos/owner/repo/issues/123/comments$'
run_review url-ssh-success comment durable-body https://git.example ssh://git@git.example/owner/repo.git owner/repo run_review url-ssh-success comment durable-body https://git.example ssh://git@git.example/owner/repo.git owner/repo
grep -q '^POST https://git.example/api/v1/repos/owner/repo/issues/123/comments$' "$CURL_LOG" grep -q '^POST https://git.example/api/v1/repos/owner/repo/issues/123/comments$' "$CURL_LOG"
# #850: an SSH remote's transport port must not be compared against the
# configured HTTP(S) API URL's port.
run_review ssh-transport-port-success comment durable-body https://git.example ssh://git@git.example:2222/owner/repo.git owner/repo
grep -q '^POST https://git.example/api/v1/repos/owner/repo/issues/123/comments$' "$CURL_LOG"
# #850: an explicit default HTTP(S) port on the remote must equal an implicit
# (portless) configured URL.
run_review explicit-default-port-success comment durable-body https://git.example https://git.example:443/owner/repo.git owner/repo
grep -q '^POST https://git.example/api/v1/repos/owner/repo/issues/123/comments$' "$CURL_LOG"
# Comment write/read-back failure modes must all fail closed.
if run_review write-transport-failure comment durable-body; then if run_review write-transport-failure comment durable-body; then
echo "Expected provider transport failure to return nonzero" >&2 echo "Expected provider transport failure to return nonzero" >&2
exit 1 exit 1
@@ -656,113 +275,4 @@ if grep -q 'Added and verified comment' "$OUTPUT_FILE"; then
exit 1 exit 1
fi fi
# Case 8 (#865 Round-4): a RESOLVABLE explicit --login override must attribute echo "pr-review.sh durable Gitea comment regression passed"
# the entire write→read-back chain to THAT login's token/identity, never the
# host-default identity. The override login carries its own token in the tea
# config, so /user, the review POST, and the exact-id read-back all authenticate
# as the override identity — and NOTHING is performed under the default identity.
run_review override-success approve "" https://git.mosaicstack.dev \
https://git.mosaicstack.dev/mosaicstack/stack.git mosaicstack/stack "$OVERRIDE_LOGIN"
grep -q 'Approved and verified Gitea PR #123 (review ID 101)' "$OUTPUT_FILE"
grep -q "^GET https://git.mosaicstack.dev/api/v1/user $OVERRIDE_LOGIN\$" "$AUTH_LOG"
grep -q "^POST https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/pulls/123/reviews $OVERRIDE_LOGIN\$" "$AUTH_LOG"
grep -q "^GET https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/pulls/123/reviews/101 $OVERRIDE_LOGIN\$" "$AUTH_LOG"
if grep -q " $ACTING_LOGIN\$" "$AUTH_LOG"; then
echo "FAIL: an explicit --login override was silently downgraded to the host-default identity" >&2
cat "$AUTH_LOG" >&2
exit 1
fi
assert_no_tea_write
# Case 9 (#865 Round-4): an UNRESOLVABLE explicit --login override (a name absent
# from the tea config) must FAIL CLOSED — nonzero exit, no success line, no review
# POST, and above all NO request performed under the host-default identity. The
# host-default best-effort fallback is reserved for the no-override path only.
if run_review override-unresolvable approve "" https://git.mosaicstack.dev \
https://git.mosaicstack.dev/mosaicstack/stack.git mosaicstack/stack "nonexistent-typo-login"; then
echo "FAIL: an unresolvable --login override was not rejected (silently used the host default)" >&2
cat "$OUTPUT_FILE" >&2
exit 1
fi
if grep -q 'Approved and verified' "$OUTPUT_FILE"; then
echo "FAIL: unresolvable --login override reported success" >&2
exit 1
fi
if grep -q '/pulls/123/reviews ' "$AUTH_LOG" && grep -qE '^POST .*/pulls/123/reviews ' "$AUTH_LOG"; then
echo "FAIL: unresolvable --login override performed a review POST" >&2
cat "$AUTH_LOG" >&2
exit 1
fi
if grep -q " $ACTING_LOGIN\$" "$AUTH_LOG"; then
echo "FAIL: unresolvable --login override fell back to the host-default identity" >&2
cat "$AUTH_LOG" >&2
exit 1
fi
# Case 10 (#865 Round-5): a --login override that IS present in tea config but
# whose URL is a DIFFERENT host than the repo remote must FAIL CLOSED (host-bound
# selection). The cross-host token must NEVER be sent to the repo host, and no
# review POST occurs.
if run_review cross-host approve "" https://git.mosaicstack.dev \
https://git.mosaicstack.dev/mosaicstack/stack.git mosaicstack/stack "$CROSS_HOST_LOGIN"; then
echo "FAIL: cross-host --login override did not fail closed" >&2
cat "$OUTPUT_FILE" >&2
exit 1
fi
if grep -q 'Approved and verified' "$OUTPUT_FILE"; then
echo "FAIL: cross-host --login override reported success" >&2
exit 1
fi
# The cross-host credential must not have performed ANY request against the repo
# host — no request may be attributed to the cross-host identity.
if grep -q " $CROSS_HOST_LOGIN\$" "$AUTH_LOG"; then
echo "FAIL: cross-host credential was sent to the repo host (cross-host leak)" >&2
cat "$AUTH_LOG" >&2
exit 1
fi
if grep -qE '^POST .*/pulls/123/reviews ' "$AUTH_LOG"; then
echo "FAIL: cross-host --login override performed a review POST" >&2
cat "$AUTH_LOG" >&2
exit 1
fi
if grep -q " $ACTING_LOGIN\$" "$AUTH_LOG"; then
echo "FAIL: cross-host --login override fell back to the host-default identity" >&2
cat "$AUTH_LOG" >&2
exit 1
fi
assert_no_temp_leak "cross-host"
# Case 11 (#865 Blocker 4): SubmitReview finalizes/reuses a pending review id
# whose persisted body is NOT this submit's body. id/author/state/head all match
# the request, so ONLY body verification can catch the divergence — it must FAIL
# CLOSED. (Submit a non-empty body so the mismatch is meaningful.)
if run_review review-body-reuse approve real-submitted-review-body; then
echo "FAIL: review with a reused/foreign body was accepted (body not verified)" >&2
cat "$OUTPUT_FILE" >&2
exit 1
fi
if grep -q 'Approved and verified' "$OUTPUT_FILE"; then
echo "FAIL: read-back did not enforce the submitted review body" >&2
exit 1
fi
assert_no_temp_leak "review-body-reuse"
# Cases 12-15 (#865 Blocker 3): a PR comment whose id/author/body are all correct
# but whose provider-returned pull_request_url is forged must FAIL CLOSED.
# Verification pins the URL's ORIGIN (scheme+host+effective-port) and FULL path
# (deployment prefix + exact owner/repo + kind + number); a bare endswith/suffix
# test would wrongly accept the look-alike-host and prefix-injection variants.
for bad_mode in comment-url-wrong-host comment-url-wrong-owner comment-url-wrong-repo comment-url-suffix-injection; do
if run_review "$bad_mode" comment durable-body; then
echo "FAIL: forged comment URL ($bad_mode) was accepted" >&2
cat "$OUTPUT_FILE" >&2
exit 1
fi
if grep -q 'Added and verified comment' "$OUTPUT_FILE"; then
echo "FAIL: forged comment URL ($bad_mode) passed verification" >&2
exit 1
fi
assert_no_temp_leak "$bad_mode"
done
echo "pr-review.sh REST review + comment create/read-back regression passed"

View File

@@ -25,7 +25,7 @@
"lint": "eslint src", "lint": "eslint src",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"test": "vitest run --passWithNoTests && pnpm run test:framework-shell", "test": "vitest run --passWithNoTests && pnpm run test:framework-shell",
"test:framework-shell": "python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh" "test:framework-shell": "python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh"
}, },
"dependencies": { "dependencies": {
"@mosaicstack/brain": "workspace:*", "@mosaicstack/brain": "workspace:*",

View File

@@ -661,27 +661,13 @@ describe('whole mutator-class lease gate', () => {
test('observer revocation and monotonic TTL expiry deny the next mutator', async () => { test('observer revocation and monotonic TTL expiry deny the next mutator', async () => {
const { socket } = await startBroker(); const { socket } = await startBroker();
const sessionId = await register(socket); const sessionId = await register(socket);
const pending = await beginVerification(socket, sessionId, 'claude', 1, 1);
// Establish the lease with a normal (non-racing) TTL first and prove it
// authorizes. This "still valid" check is setup, not a TTL-expiry
// assertion, so it must not share a lease with a 1-second TTL: on a
// contended push-CI host, scheduling delay alone between promote() and
// this authorize() call can consume that entire 1-second margin and
// spuriously deny it (CI#1945). Using a generous TTL here removes that
// real-time race without touching lease-gate security semantics.
const pending = await beginVerification(socket, sessionId, 'claude');
await promote(socket, sessionId, pending.receipt_challenge!); await promote(socket, sessionId, pending.receipt_challenge!);
expect(await authorize(socket, sessionId, 'claude', 'Bash')).toMatchObject({ expect(await authorize(socket, sessionId, 'claude', 'Bash')).toMatchObject({
ok: true, ok: true,
decision: 'allow', decision: 'allow',
}); });
// A dedicated, isolated short-TTL lease drives the deliberate monotonic
// expiry demonstration below. It is never used for anything but the
// wait-then-expire assertion, so there is no setup work racing its
// 1-second window.
const shortLived = await beginVerification(socket, sessionId, 'claude', 1, 1, 2);
await promote(socket, sessionId, shortLived.receipt_challenge!);
await new Promise((resolve) => setTimeout(resolve, 1_100)); await new Promise((resolve) => setTimeout(resolve, 1_100));
expect(await authorize(socket, sessionId, 'claude', 'Bash')).toMatchObject({ expect(await authorize(socket, sessionId, 'claude', 'Bash')).toMatchObject({
ok: false, ok: false,
@@ -689,7 +675,7 @@ describe('whole mutator-class lease gate', () => {
decision: 'deny', decision: 'deny',
}); });
const refreshed = await beginVerification(socket, sessionId, 'claude', 1, 300, 3); const refreshed = await beginVerification(socket, sessionId, 'claude', 1, 300, 2);
await promote(socket, sessionId, refreshed.receipt_challenge!); await promote(socket, sessionId, refreshed.receipt_challenge!);
expect( expect(
await request(socket, { await request(socket, {

View File

@@ -1,50 +0,0 @@
# Skill: glpi-create — Open a New GLPI Ticket
> Create a new GLPI helpdesk ticket. Mutates GLPI — confirm the details before running.
## When to use
- Logging a new incident or request that should live in the helpdesk queue.
## Required information
- **title** — short subject line.
- **content** — description of the issue / request.
## Optional
- **priority** — `1`=VeryLow, `2`=Low, `3`=Medium (default), `4`=High, `5`=VeryHigh, `6`=Major.
- **type** — `1`=Incident (default), `2`=Request.
## Command
Wraps the existing tooling:
```bash
~/.config/mosaic/tools/glpi/ticket-create.sh \
-t "<title>" \
-c "<content>" \
[-p <priority>] \
[-y <type>] \
[-f json]
```
Example:
```bash
~/.config/mosaic/tools/glpi/ticket-create.sh \
-t "Paint-area camera install" \
-c "Ordered 2 cameras for Paint and stock; schedule mounting + NVR config." \
-p 3 -y 2
```
## After creating
- Note the returned **ticket ID** — you'll need it for **[[glpi-followup]]** and
**[[glpi-solve]]**.
- If it should also be tracked as brain work, add a matching task (see the `add-task` skill).
## Guardrails
- Confirm title/content/priority with the user before creating — a ticket is outward-facing.
- Never echo GLPI tokens.

View File

@@ -1,56 +0,0 @@
# Skill: glpi-followup — Add a Followup to a GLPI Ticket
> Post a followup (comment / progress note / resolution writeup) to a GLPI ticket.
> This documents work but does **not** change the ticket status — to close a ticket
> out, follow with **[[glpi-solve]]** to set status to Solved.
## When to use
- Recording progress, a decision, or a root-cause/resolution note on a ticket.
- The documentation step that usually precedes closing a ticket out (`glpi-solve`).
## Critical quirk
Use the **top-level `/ITILFollowup` endpoint**, NOT `/Ticket/<id>/ITILFollowup`. The
sub-resource path returns permission errors even with a Super-Admin profile.
## Procedure
### 1. Session + creds
```bash
SESSION=$(~/.config/mosaic/tools/glpi/session-init.sh -q)
source ~/.config/mosaic/tools/_lib/credentials.sh && load_credentials glpi
```
### 2. Post the followup
```bash
TICKET_ID=<id>
CONTENT="<the followup text>"
curl -sk -X POST "${GLPI_URL}/ITILFollowup" \
-H "App-Token: $GLPI_APP_TOKEN" \
-H "Session-Token: $SESSION" \
-H "Content-Type: application/json" \
-d "$(jq -n --argjson id "$TICKET_ID" --arg c "$CONTENT" \
'{input:{itemtype:"Ticket", items_id:$id, content:$c}}')"
```
Expect HTTP 201. Building the payload with `jq` keeps quotes/newlines in the content safe.
### 3. Long or multi-paragraph content
Write the note to a file first, then read it into the payload:
```bash
curl -sk -X POST "${GLPI_URL}/ITILFollowup" \
-H "App-Token: $GLPI_APP_TOKEN" -H "Session-Token: $SESSION" \
-H "Content-Type: application/json" \
-d "$(jq -n --argjson id "$TICKET_ID" --rawfile c /path/to/note.md \
'{input:{itemtype:"Ticket", items_id:$id, content:$c}}')"
```
## Guardrails
- Never echo the GLPI app/user/session tokens.
- A followup alone leaves the ticket open. If the work is done, run **[[glpi-solve]]** next.

View File

@@ -1,57 +0,0 @@
# Skill: glpi-list — Query GLPI Tickets
> Quick lookups of GLPI helpdesk tickets by status or recency. Read-only.
## When to use
- "What tickets are open / pending?" · "Show recent tickets" · finding a ticket ID
before running **[[glpi-followup]]** or **[[glpi-solve]]**.
## Command
Wraps the existing tooling:
```bash
GLPI=~/.config/mosaic/tools/glpi
# Most recent tickets (default 50, newest first)
"$GLPI/ticket-list.sh"
# Filter by status: new | processing | pending | solved | closed
"$GLPI/ticket-list.sh" -s pending
# JSON output (for parsing / piping to jq) and a custom limit
"$GLPI/ticket-list.sh" -s processing -f json -l 20
```
Status IDs: 1 New · 2/3 Processing · 4 Pending · 5 Solved · 6 Closed.
## Details lookup for one ticket
When you have an ID and want the full record:
```bash
SESSION=$(~/.config/mosaic/tools/glpi/session-init.sh -q)
source ~/.config/mosaic/tools/_lib/credentials.sh && load_credentials glpi
curl -sk "${GLPI_URL}/Ticket/<id>?expand_dropdowns=true" \
-H "App-Token: $GLPI_APP_TOKEN" -H "Session-Token: $SESSION" \
| jq '{id, name, status, date, date_mod}'
# Followups on a ticket
curl -sk "${GLPI_URL}/Ticket/<id>/ITILFollowup" \
-H "App-Token: $GLPI_APP_TOKEN" -H "Session-Token: $SESSION" \
| jq '.[] | {date, content}'
```
(Reading followups via the sub-resource is fine — only _creating_ them requires the
top-level `/ITILFollowup` endpoint. See **[[glpi-followup]]**.)
## Present to user
Group by status, one line per ticket: `#<id> · <title> · <status> · <last-modified>`.
Use neutral phrasing — no "OVERDUE"/"URGENT".
## Guardrails
- Read-only. Never echo GLPI tokens.
- To sync tickets into brain data instead, use `python tools/sync_glpi.py` (not this skill).

View File

@@ -1,96 +0,0 @@
# Skill: glpi-solve — Close Out a GLPI Ticket
> Properly close out a completed GLPI helpdesk ticket. Completing the work is not
> enough — the ticket **status must be set to "Solved"**, which is what triggers
> GLPI's config-driven auto-close. Posting a resolution followup documents the work
> but does **not** change status, so a ticket left at Solved-less status stays open.
## When to use
- Any time work on a GLPI ticket is finished and it should be closed out.
- After posting a root-cause / resolution writeup as an `/ITILFollowup`.
- During a cleanup sweep of tickets that are done in reality but still open in GLPI.
## The rule (from an operator, 2026-07-20)
**"Solved" is the correct terminal state to set — not "Closed."** GLPI is configured
to auto-close Solved tickets after its delay. If you only post a followup and never set
status, the ticket sits open (this bit us on a real incident where resolution followups
were posted but status was never advanced, leaving tickets open, which the operator had
to mark Solved by hand).
Close-out = **followup (optional but preferred) + set status to Solved.**
## GLPI status IDs
| ID | Status | |
| ----- | --------------------- | -------------------------------------------- |
| 1 | New | |
| 2 | Processing (assigned) | |
| 3 | Processing (planned) | |
| 4 | Pending / Waiting | |
| **5** | **Solved** | ← set this on close-out |
| 6 | Closed | ← happens automatically; do not set manually |
## Procedure
### 1. Get a session token
```bash
SESSION=$(~/.config/mosaic/tools/glpi/session-init.sh -q)
source ~/.config/mosaic/tools/_lib/credentials.sh && load_credentials glpi
```
### 2. (Preferred) Post the resolution followup
Use the **top-level `/ITILFollowup` endpoint** — the `/Ticket/<id>/ITILFollowup`
sub-resource returns permission errors even as Super-Admin (known GLPI quirk).
```bash
TICKET_ID=<id>
curl -sk -X POST "${GLPI_URL}/ITILFollowup" \
-H "App-Token: $GLPI_APP_TOKEN" \
-H "Session-Token: $SESSION" \
-H "Content-Type: application/json" \
-d "{\"input\":{\"itemtype\":\"Ticket\",\"items_id\":${TICKET_ID},\"content\":\"<resolution summary>\"}}"
```
### 3. Set status to Solved (the step that actually closes it out)
```bash
curl -sk -X PUT "${GLPI_URL}/Ticket/${TICKET_ID}" \
-H "App-Token: $GLPI_APP_TOKEN" \
-H "Session-Token: $SESSION" \
-H "Content-Type: application/json" \
-d "{\"input\":{\"id\":${TICKET_ID},\"status\":5}}"
```
Expect HTTP 200/201. GLPI will auto-close it later per its config — leave status at 5.
### 4. Verify
```bash
curl -sk "${GLPI_URL}/Ticket/${TICKET_ID}?expand_dropdowns=true" \
-H "App-Token: $GLPI_APP_TOKEN" -H "Session-Token: $SESSION" \
| jq '{id, name, status}'
```
`status` should read `Solved` (or `5`).
## Optional: sweep for done-but-open tickets
List tickets still open (New/Processing/Pending) to spot ones whose work is actually
finished but were never marked Solved:
```bash
~/.config/mosaic/tools/glpi/ticket-list.sh -s processing -f table
~/.config/mosaic/tools/glpi/ticket-list.sh -s pending -f table
```
Review each; for any that are genuinely resolved, run steps 23.
## Guardrails
- Read-only until you intend to close — confirm the ticket is actually done first.
- Never echo the GLPI app/user/session tokens.
- Set **Solved (5)**, never Closed (6) — auto-close owns that transition.

View File

@@ -1,62 +0,0 @@
# Skill: glpi-sweep — Find Done-But-Open Tickets
> Read-only sweep for tickets that are finished in reality but still sitting open in
> GLPI (never moved to Solved). Surfaces the exact miss an operator caught on 2026-07-20
> (a real incident where an affected ticket had resolution followups posted but was left
> open). For each one that's genuinely done, close it out with **[[glpi-solve]]**.
## When to use
- Periodic hygiene pass (e.g. before a weekly update or month-end).
- After a burst of ticket work, to catch any you resolved-in-followup but never Solved.
## Why this exists
Posting an `/ITILFollowup` documents work but does **not** change status. Tickets only
auto-close once set to **Solved (status 5)**. Anything left at New/Processing/Pending
stays open indefinitely. This sweep finds those.
## Procedure
### 1. List still-open tickets by status
```bash
GLPI=~/.config/mosaic/tools/glpi
"$GLPI/ticket-list.sh" -s new -f table
"$GLPI/ticket-list.sh" -s processing -f table
"$GLPI/ticket-list.sh" -s pending -f table
```
(GLPI status IDs: 1 New · 2/3 Processing · 4 Pending · 5 Solved · 6 Closed.)
### 2. Triage
For each open ticket, judge whether the underlying work is actually finished — check
its latest followups and cross-reference brain tasks / recent work. Read-only here;
change nothing yet.
Reasonable "probably done" signals:
- A resolution/root-cause followup already posted, but status never advanced.
- The related brain task is `done`, or the fix shipped and was confirmed.
- Requester confirmed resolution but the ticket was never Solved.
### 3. Present the candidates
List them for review before touching anything — never bulk-solve blindly:
```
Open tickets that look resolved:
- #<id> "<title>" — <why it looks done> → glpi-solve?
```
### 4. Close out the confirmed ones
For each ticket the user (or clear evidence) confirms is done, run **[[glpi-solve]]**
(optionally **[[glpi-followup]]** first if a closing note is warranted).
## Guardrails
- Read-only until a ticket is confirmed done — do not auto-solve on a guess.
- Never echo GLPI tokens.
- Set **Solved (5)**, never Closed (6) — GLPI auto-close owns that transition.