Compare commits
14 Commits
fix/812-pr
...
fix/865-te
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9384f0bc0a | ||
|
|
16481ece3d | ||
|
|
10fdd49e32 | ||
|
|
a27f1fa7df | ||
| 4e5af23214 | |||
|
|
880c28b191 | ||
|
|
7bc2dfb6c8 | ||
| b0d78d8632 | |||
| 344d86a635 | |||
| acd7d380f6 | |||
| 3b70c66c07 | |||
| 11d2818453 | |||
| aa999daf1b | |||
| 77c9a82614 |
@@ -64,7 +64,7 @@ Active workstream is **W1 — Federation v1**. Workers should:
|
||||
| FCM-M3-002 | in-progress | Add isolated systemd/tmux lifecycle, drift, socket, unmanaged-session, crash, and rollback acceptance coverage | #758 | sonnet | mosaicstack/stack | `test/758-reconciler-lifecycle-gates` | FCM-M3-001 | 25K | Canonical v2 named-socket + legacy-v1 default-server boundaries; fake adapters/temp fixtures only |
|
||||
| FCM-M4-001 | done | Implement field-complete v1-to-v2 inventory/preview/migrator with alias, lifecycle, env-quarantine, and remote/connector disposition evidence | #758 | codex | mosaicstack/stack | `feat/758-v1-v2-migrator` | FCM-M1-003, FCM-M3-001 | 35K | PR #788; final head `d63bb0206a1d312ab8352ec1d3ca3631146b0baa`; tree `4da210da9a71b035130d4160a4a2e691bdfde2da`; squash `9745bc3f29c26b021a478b7ad03cfb494f6c9de3`; descendant-main pipeline 1855 terminal success |
|
||||
| FCM-M4-002 | not-started | Add reversible canary migration, rollback, stale-projection/orphan classification, and current-host 9-managed/3-unmanaged fixture coverage | #758 | sonnet | mosaicstack/stack | `test/758-migration-rollback-gates` | FCM-M4-001, FCM-M3-002 | 25K | HOLD: never starts a previously stopped agent or kills an unproven unmanaged session; not authorized by FCM-M5-001 |
|
||||
| FCM-M5-001 | in-progress | Deliver the accepted fleet documentation IA, how-to/operations/migration references, and link/example validation | #758 | haiku | mosaicstack/stack | `docs/758-fleet-config-operator-docs` | FCM-M1-003, FCM-M2-002, FCM-M3-001, FCM-M4-001 | 24K | Sole owner: this FCM-M5-001 delivery on the recorded branch; must close every checklist item or record an approved deferral |
|
||||
| FCM-M5-001 | done | Deliver the accepted fleet documentation IA, how-to/operations/migration references, and link/example validation | #758 | haiku | mosaicstack/stack | `docs/758-fleet-config-operator-docs` | FCM-M1-003, FCM-M2-002, FCM-M3-001, FCM-M4-001 | 24K | #789 content squash 627cf2bb; de-flake repair PR#851/#849 squash 77c9a826; completion proof wp1937 @aa999daf push/ci step 49632 recovery_runtime_unittest.py 3/3 OK (closes wp1932 step 49576 Errno111) |
|
||||
| FCM-M5-002 | not-started | Package/update asset-drift checks, rolling local canary, independent validation certificate, and release evidence | #758 | sonnet | mosaicstack/stack | `feat/758-fleet-config-release-gate` | FCM-M3-002, FCM-M4-002, FCM-M5-001 | 30K | HOLD: final #758 gate; quality, independent code/security review, validator certificate, merge-gate approval, and green CI remain out of M5-001 |
|
||||
|
||||
## Thin-core prompt diet (#528) — feat/contract-thin-core
|
||||
|
||||
@@ -4,6 +4,26 @@ These scripts provide host-aware GitHub and Gitea issue, pull-request, milestone
|
||||
|
||||
## 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 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).
|
||||
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.
|
||||
|
||||
`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.
|
||||
**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:
|
||||
|
||||
- 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, or, for reviews, its state matches the requested action and its reviewed `commit_id` equals the PR head. 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.
|
||||
|
||||
**Full pagination.** After the exact-id read-back, each wrapper also confirms the created id is enumerable in the record list, walking every page (`?limit=&page=1,2,…` until a short/empty page) so a record that lands beyond the first page is still found regardless of how many comments or reviews already exist.
|
||||
|
||||
## `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. 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.
|
||||
|
||||
@@ -91,13 +91,19 @@ remote = urlparse(f"//{remote_host}")
|
||||
if configured.scheme not in {"http", "https"} or configured.hostname != remote.hostname:
|
||||
raise SystemExit(1)
|
||||
|
||||
configured_port = configured.port
|
||||
remote_port = remote.port
|
||||
if remote_port is None:
|
||||
default_port = 80 if configured.scheme == "http" else 443
|
||||
if configured_port not in {None, default_port}:
|
||||
raise SystemExit(1)
|
||||
elif configured_port != remote_port:
|
||||
# Normalize by scheme: an implicit (portless) HTTP(S) URL and its explicit
|
||||
# default-port form (":80" for http, ":443" for https) name the same
|
||||
# provider endpoint. Apply that equivalence symmetrically -- whichever side
|
||||
# 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
|
||||
normalized_configured = configured.port if configured.port is not None else default_port
|
||||
normalized_remote = remote.port if remote.port is not None else default_port
|
||||
if normalized_configured != normalized_remote:
|
||||
raise SystemExit(1)
|
||||
raise SystemExit(0)
|
||||
PY
|
||||
@@ -432,7 +438,11 @@ get_remote_host() {
|
||||
fi
|
||||
if [[ "$remote_url" =~ ^ssh://([^/]+)/ ]]; then
|
||||
local host="${BASH_REMATCH[1]}"
|
||||
echo "${host##*@}"
|
||||
host="${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
|
||||
fi
|
||||
if [[ "$remote_url" =~ ^git@([^:]+): ]]; then
|
||||
@@ -553,6 +563,54 @@ get_gitea_token() {
|
||||
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.
|
||||
# Prints the token on success; returns non-zero (no output) if the config or a
|
||||
# matching login token cannot be found. Callers must not log the result.
|
||||
get_gitea_token_for_login() {
|
||||
local login_name="$1" 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" python3 - "$config_file" <<'PY'
|
||||
import os
|
||||
import sys
|
||||
|
||||
try:
|
||||
import yaml
|
||||
except ImportError:
|
||||
raise SystemExit(1)
|
||||
|
||||
try:
|
||||
with open(sys.argv[1], encoding="utf-8") as handle:
|
||||
config = yaml.safe_load(handle)
|
||||
except (OSError, yaml.YAMLError):
|
||||
raise SystemExit(1)
|
||||
|
||||
wanted = os.environ["LOGIN_NAME"]
|
||||
logins = config.get("logins") if isinstance(config, dict) else None
|
||||
if not isinstance(logins, list):
|
||||
raise SystemExit(1)
|
||||
|
||||
for login in logins:
|
||||
if not isinstance(login, dict):
|
||||
continue
|
||||
if str(login.get("name") or "") == wanted:
|
||||
token = login.get("token")
|
||||
if isinstance(token, str) and token:
|
||||
print(token)
|
||||
raise SystemExit(0)
|
||||
break
|
||||
|
||||
raise SystemExit(1)
|
||||
PY
|
||||
}
|
||||
|
||||
# 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.
|
||||
get_gitea_basic_auth() {
|
||||
|
||||
@@ -1,6 +1,26 @@
|
||||
#!/bin/bash
|
||||
# issue-comment.sh - Add a comment to an issue on GitHub or Gitea
|
||||
# Usage: issue-comment.sh -i <issue_number> -c <comment>
|
||||
# Usage: issue-comment.sh -i <issue_number> -c <comment> [--login <name>]
|
||||
#
|
||||
# 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
|
||||
|
||||
@@ -10,6 +30,7 @@ source "$SCRIPT_DIR/detect-platform.sh"
|
||||
# Parse arguments
|
||||
ISSUE_NUMBER=""
|
||||
COMMENT=""
|
||||
LOGIN_OVERRIDE=""
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
@@ -21,12 +42,17 @@ while [[ $# -gt 0 ]]; do
|
||||
COMMENT="$2"
|
||||
shift 2
|
||||
;;
|
||||
-l|--login)
|
||||
LOGIN_OVERRIDE="$2"
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
echo "Usage: issue-comment.sh -i <issue_number> -c <comment>"
|
||||
echo "Usage: issue-comment.sh -i <issue_number> -c <comment> [--login <name>]"
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " -i, --issue Issue number (required)"
|
||||
echo " -c, --comment Comment text (required)"
|
||||
echo " -l, --login Override the detected Gitea tea login for this call"
|
||||
echo " -h, --help Show this help"
|
||||
exit 0
|
||||
;;
|
||||
@@ -49,20 +75,319 @@ fi
|
||||
|
||||
detect_platform >/dev/null
|
||||
|
||||
# Resolve and cache the Gitea REST endpoint + token for the current remote,
|
||||
# bound to a SPECIFIC login identity ($1). Populates GITEA_API_ROOT (…/api/v1),
|
||||
# GITEA_API_BASE (…/api/v1/repos/<slug>), and GITEA_API_TOKEN.
|
||||
#
|
||||
# The token is resolved for the EFFECTIVE login (the --login override when
|
||||
# given, otherwise the detected default) so that the single credential used for
|
||||
# the write ALSO drives the /user identity read and the read-back — write token
|
||||
# and read-back token are the same identity by construction (this is the
|
||||
# credential-ordering fix: a --login override is no longer written under one
|
||||
# credential and verified under a different default one). Falls back to the
|
||||
# host-scoped credential only when the login has no token in tea's own config.
|
||||
# Returns non-zero (clear stderr) on any resolution failure.
|
||||
gitea_resolve_api_for_login() {
|
||||
local effective_login="$1" host configured_url repo
|
||||
|
||||
host=$(get_remote_host)
|
||||
GITEA_API_TOKEN=$(get_gitea_token_for_login "$effective_login") \
|
||||
|| GITEA_API_TOKEN=$(get_gitea_token "$host") || {
|
||||
echo "Error: Gitea token not found for login '$effective_login' (comment write/read-back)" >&2
|
||||
return 1
|
||||
}
|
||||
configured_url=$(get_gitea_url_for_host "$host") || {
|
||||
echo "Error: Configured Gitea URL not found for comment read-back verification" >&2
|
||||
return 1
|
||||
}
|
||||
repo=$(get_gitea_repo_slug_for_url "$configured_url") || {
|
||||
echo "Error: Could not resolve Gitea owner/repository relative to configured URL" >&2
|
||||
return 1
|
||||
}
|
||||
GITEA_API_ROOT="${configured_url%/}/api/v1"
|
||||
GITEA_API_BASE="$GITEA_API_ROOT/repos/$repo"
|
||||
return 0
|
||||
}
|
||||
|
||||
# Fetch every page of a Gitea list endpoint into $2 (merged into one JSON
|
||||
# array). Gitea paginates list responses, so a single-page read would
|
||||
# false-negative once a newly created record lands beyond page 1. Walks
|
||||
# page=1,2,… until a short page (fewer than the requested limit) or an empty
|
||||
# page is returned, so the merged array is exhaustive. $1 is the endpoint URL
|
||||
# with NO query string. Returns non-zero (clear stderr) on any transport /
|
||||
# HTTP / parse failure.
|
||||
gitea_fetch_all() {
|
||||
local base_url="$1" dest="$2" page=1 limit=50 status page_file count
|
||||
|
||||
printf '[]' > "$dest"
|
||||
while :; do
|
||||
page_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-issue-comment-page.XXXXXX")
|
||||
if ! status=$(curl -sS -o "$page_file" -w '%{http_code}' \
|
||||
-H "Authorization: token $GITEA_API_TOKEN" \
|
||||
"${base_url}?limit=${limit}&page=${page}"); then
|
||||
rm -f "$page_file"
|
||||
echo "Error: Gitea list read transport failed" >&2
|
||||
return 1
|
||||
fi
|
||||
if [[ "$status" != "200" ]]; then
|
||||
rm -f "$page_file"
|
||||
echo "Error: Gitea list read failed with HTTP $status" >&2
|
||||
return 1
|
||||
fi
|
||||
count=$(DEST="$dest" python3 - "$page_file" <<'PY'
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
try:
|
||||
with open(os.environ["DEST"], encoding="utf-8") as merged_file:
|
||||
merged = json.load(merged_file)
|
||||
with open(sys.argv[1], encoding="utf-8") as page_file:
|
||||
page = json.load(page_file)
|
||||
if not isinstance(page, list):
|
||||
raise ValueError("page response is not a list")
|
||||
merged.extend(item for item in page if isinstance(item, dict))
|
||||
with open(os.environ["DEST"], "w", encoding="utf-8") as merged_file:
|
||||
json.dump(merged, merged_file)
|
||||
except (OSError, json.JSONDecodeError, TypeError, ValueError) as error:
|
||||
print(f"Error: could not merge Gitea list page: {error}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
print(len(page))
|
||||
PY
|
||||
) || { rm -f "$page_file"; return 1; }
|
||||
rm -f "$page_file"
|
||||
[[ "$count" -lt "$limit" ]] && break
|
||||
page=$((page + 1))
|
||||
if [[ "$page" -gt 1000 ]]; then
|
||||
echo "Error: Gitea list pagination exceeded 1000 pages" >&2
|
||||
return 1
|
||||
fi
|
||||
done
|
||||
return 0
|
||||
}
|
||||
|
||||
# Resolve the login of the identity the API token authenticates as (GET
|
||||
# /user). Used to attribute a read-back record to THIS invocation's writer so
|
||||
# a concurrent write from a DIFFERENT identity cannot satisfy verification.
|
||||
# Prints the login on success.
|
||||
gitea_authenticated_login() {
|
||||
local response_file status
|
||||
|
||||
response_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-issue-comment-whoami.XXXXXX")
|
||||
trap 'rm -f "$response_file"' RETURN
|
||||
|
||||
if ! status=$(curl -sS -o "$response_file" -w '%{http_code}' \
|
||||
-H "Authorization: token $GITEA_API_TOKEN" \
|
||||
"$GITEA_API_ROOT/user"); then
|
||||
echo "Error: Gitea authenticated-identity read transport failed" >&2
|
||||
return 1
|
||||
fi
|
||||
if [[ "$status" != "200" ]]; then
|
||||
echo "Error: Gitea authenticated-identity read failed with HTTP $status" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
python3 - "$response_file" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
|
||||
try:
|
||||
with open(sys.argv[1], encoding="utf-8") as response:
|
||||
user = json.load(response)
|
||||
login = user.get("login") if isinstance(user, dict) else None
|
||||
if not isinstance(login, str) or not login:
|
||||
raise ValueError("missing authenticated login")
|
||||
except (OSError, json.JSONDecodeError, TypeError, ValueError) as error:
|
||||
print(f"Error: could not resolve authenticated Gitea identity: {error}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
print(login)
|
||||
PY
|
||||
}
|
||||
|
||||
# Confirm that the comment CREATED by this invocation ($2 = its provider id) is
|
||||
# enumerable in the issue's full, paginated comment listing and is authored by
|
||||
# the acting identity. Gitea paginates list responses, so a comment created
|
||||
# beyond page 1 must still be found; walking every page also proves the created
|
||||
# id is durably indexed against THIS issue rather than merely retrievable by id.
|
||||
# Returns non-zero (clear stderr) if the exact created id is not present with a
|
||||
# matching author.
|
||||
gitea_confirm_comment_enumerable() {
|
||||
local issue_number="$1" created_id="$2" acting_login="$3" merged_file
|
||||
|
||||
merged_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-issue-comment-readback.XXXXXX")
|
||||
trap 'rm -f "$merged_file"' RETURN
|
||||
|
||||
gitea_fetch_all "$GITEA_API_BASE/issues/$issue_number/comments" "$merged_file" || return 1
|
||||
|
||||
CREATED_COMMENT_ID="$created_id" ACTING_LOGIN="$acting_login" \
|
||||
python3 - "$merged_file" <<'PY'
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
try:
|
||||
with open(sys.argv[1], encoding="utf-8") as response:
|
||||
comments = json.load(response)
|
||||
if not isinstance(comments, list):
|
||||
raise ValueError("response is not a comment list")
|
||||
created_id = int(os.environ["CREATED_COMMENT_ID"])
|
||||
acting_login = os.environ["ACTING_LOGIN"]
|
||||
match = next(
|
||||
(
|
||||
c for c in comments
|
||||
if isinstance(c, dict)
|
||||
and c.get("id") == created_id
|
||||
and (c.get("user") or {}).get("login") == acting_login
|
||||
),
|
||||
None,
|
||||
)
|
||||
if match is None:
|
||||
raise ValueError(
|
||||
f"created comment id {created_id} is not enumerable in the issue's "
|
||||
"paginated comment list under the acting identity"
|
||||
)
|
||||
except (OSError, json.JSONDecodeError, KeyError, TypeError, ValueError) as error:
|
||||
print(f"Error: Gitea comment enumeration check failed: {error}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
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_ISSUE_NUMBER="$issue_number" \
|
||||
python3 - "$readback_file" <<'PY' || return 1
|
||||
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"]
|
||||
acting_login = os.environ["ACTING_LOGIN"]
|
||||
expected_suffix = (
|
||||
f"/repos/{os.environ['EXPECTED_REPO_SLUG']}"
|
||||
f"/issues/{os.environ['EXPECTED_ISSUE_NUMBER']}"
|
||||
)
|
||||
issue_path = urlparse(comment.get("issue_url", "")).path.rstrip("/")
|
||||
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 issue_path.endswith(expected_suffix):
|
||||
raise ValueError("created comment does not belong to this issue")
|
||||
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
|
||||
|
||||
gitea_confirm_comment_enumerable "$issue_number" "$created_id" "$acting_login" || return 1
|
||||
|
||||
echo "$created_id"
|
||||
return 0
|
||||
}
|
||||
|
||||
if [[ "$PLATFORM" == "github" ]]; then
|
||||
gh issue comment "$ISSUE_NUMBER" --body "$COMMENT"
|
||||
echo "Added comment to GitHub issue #$ISSUE_NUMBER"
|
||||
elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||
# Build the invocation as an argv array (not unquoted $(get_gitea_repo_args)
|
||||
# word-splitting) so the comment body — including Markdown backticks, $(...),
|
||||
# and quotes — is passed verbatim and never re-split or shell-evaluated.
|
||||
REPO_SLUG=$(get_repo_slug)
|
||||
GITEA_LOGIN_NAME=$(get_gitea_login) || {
|
||||
echo "Error: could not resolve a Gitea login for this repo; cannot comment on issue #$ISSUE_NUMBER." >&2
|
||||
# Resolve the login this comment should be attributed to: the --login
|
||||
# override when given, otherwise the detected default for this repo's host.
|
||||
# A --login override always wins. Otherwise name this repo host's login only
|
||||
# as a best effort: the login name merely selects a per-login token, and
|
||||
# gitea_resolve_api_for_login falls back to the host credential
|
||||
# (get_gitea_token) when no tea login is named, so the default credential
|
||||
# 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.
|
||||
gitea_resolve_api_for_login "$EFFECTIVE_LOGIN" || 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
|
||||
}
|
||||
tea issue comment "$ISSUE_NUMBER" "$COMMENT" --repo "$REPO_SLUG" --login "$GITEA_LOGIN_NAME"
|
||||
echo "Added comment to Gitea issue #$ISSUE_NUMBER"
|
||||
echo "Added and verified comment on Gitea issue #$ISSUE_NUMBER (comment ID $comment_id)"
|
||||
else
|
||||
echo "Error: Unknown platform"
|
||||
exit 1
|
||||
|
||||
@@ -1,6 +1,21 @@
|
||||
#!/bin/bash
|
||||
# pr-review.sh - Review a pull request on GitHub or Gitea
|
||||
# Usage: pr-review.sh -n <pr_number> -a <action> [-c <comment>]
|
||||
# Usage: pr-review.sh -n <pr_number> -a <action> [-c <comment>] [--login <name>]
|
||||
#
|
||||
# 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
|
||||
|
||||
@@ -12,6 +27,7 @@ source "$SCRIPT_DIR/detect-platform.sh"
|
||||
PR_NUMBER=""
|
||||
ACTION=""
|
||||
COMMENT=""
|
||||
LOGIN_OVERRIDE=""
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
@@ -27,13 +43,18 @@ while [[ $# -gt 0 ]]; do
|
||||
COMMENT="$2"
|
||||
shift 2
|
||||
;;
|
||||
-l|--login)
|
||||
LOGIN_OVERRIDE="$2"
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
echo "Usage: pr-review.sh -n <pr_number> -a <action> [-c <comment>]"
|
||||
echo "Usage: pr-review.sh -n <pr_number> -a <action> [-c <comment>] [--login <name>]"
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " -n, --number PR number (required)"
|
||||
echo " -a, --action Review action: approve, request-changes, comment (required)"
|
||||
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"
|
||||
exit 0
|
||||
;;
|
||||
@@ -56,6 +77,438 @@ fi
|
||||
|
||||
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_PR_NUMBER="$pr_number" \
|
||||
python3 - "$readback_file" <<'PY' || return 1
|
||||
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"]
|
||||
acting_login = os.environ["ACTING_LOGIN"]
|
||||
expected_suffix = (
|
||||
f"/repos/{os.environ['EXPECTED_REPO_SLUG']}"
|
||||
f"/issues/{os.environ['EXPECTED_PR_NUMBER']}"
|
||||
)
|
||||
issue_path = urlparse(comment.get("issue_url", "")).path.rstrip("/")
|
||||
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 issue_path.endswith(expected_suffix):
|
||||
raise ValueError("created comment does not belong to this PR")
|
||||
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 the login has no token in tea's config.
|
||||
# Returns non-zero (clear stderr) on any resolution failure.
|
||||
gitea_resolve_api_for_login() {
|
||||
local effective_login="$1" host configured_url repo
|
||||
|
||||
host=$(get_remote_host)
|
||||
GITEA_API_TOKEN=$(get_gitea_token_for_login "$effective_login") \
|
||||
|| GITEA_API_TOKEN=$(get_gitea_token "$host") || {
|
||||
echo "Error: Gitea token not found for login '$effective_login' (review write/read-back)" >&2
|
||||
return 1
|
||||
}
|
||||
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"
|
||||
return 0
|
||||
}
|
||||
|
||||
# Fetch every page of a Gitea list endpoint into $2 (merged into one JSON
|
||||
# array). Gitea paginates list responses, so a single-page read would
|
||||
# false-negative once a newly created review/comment lands beyond page 1.
|
||||
# Walks page=1,2,… until a short or empty page is returned so the merged array
|
||||
# is exhaustive. $1 is the endpoint URL with NO query string. Returns non-zero
|
||||
# (clear stderr) on any transport / HTTP / parse failure.
|
||||
gitea_fetch_all() {
|
||||
local base_url="$1" dest="$2" page=1 limit=50 status page_file count
|
||||
|
||||
printf '[]' > "$dest"
|
||||
while :; do
|
||||
page_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-pr-review-page.XXXXXX")
|
||||
if ! status=$(curl -sS -o "$page_file" -w '%{http_code}' \
|
||||
-H "Authorization: token $GITEA_API_TOKEN" \
|
||||
"${base_url}?limit=${limit}&page=${page}"); then
|
||||
rm -f "$page_file"
|
||||
echo "Error: Gitea list read transport failed" >&2
|
||||
return 1
|
||||
fi
|
||||
if [[ "$status" != "200" ]]; then
|
||||
rm -f "$page_file"
|
||||
echo "Error: Gitea list read failed with HTTP $status" >&2
|
||||
return 1
|
||||
fi
|
||||
count=$(DEST="$dest" python3 - "$page_file" <<'PY'
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
try:
|
||||
with open(os.environ["DEST"], encoding="utf-8") as merged_file:
|
||||
merged = json.load(merged_file)
|
||||
with open(sys.argv[1], encoding="utf-8") as page_file:
|
||||
page = json.load(page_file)
|
||||
if not isinstance(page, list):
|
||||
raise ValueError("page response is not a list")
|
||||
merged.extend(item for item in page if isinstance(item, dict))
|
||||
with open(os.environ["DEST"], "w", encoding="utf-8") as merged_file:
|
||||
json.dump(merged, merged_file)
|
||||
except (OSError, json.JSONDecodeError, TypeError, ValueError) as error:
|
||||
print(f"Error: could not merge Gitea list page: {error}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
print(len(page))
|
||||
PY
|
||||
) || { rm -f "$page_file"; return 1; }
|
||||
rm -f "$page_file"
|
||||
[[ "$count" -lt "$limit" ]] && break
|
||||
page=$((page + 1))
|
||||
if [[ "$page" -gt 1000 ]]; then
|
||||
echo "Error: Gitea list pagination exceeded 1000 pages" >&2
|
||||
return 1
|
||||
fi
|
||||
done
|
||||
return 0
|
||||
}
|
||||
|
||||
# Resolve the login of the identity the API token authenticates as (GET
|
||||
# /user). Used to attribute a read-back 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
|
||||
}
|
||||
|
||||
# Confirm that the review CREATED by this action ($2 = its provider id) is
|
||||
# enumerable in the PR's full, paginated review listing, authored by the acting
|
||||
# identity, in the expected state. Gitea paginates review lists, so a review
|
||||
# created beyond page 1 must still be found; walking every page also proves the
|
||||
# created id is durably indexed against THIS PR rather than merely retrievable
|
||||
# by id. Returns non-zero (clear stderr) if the exact created id is absent or
|
||||
# does not match author/state.
|
||||
gitea_confirm_review_enumerable() {
|
||||
local pr_number="$1" created_id="$2" expected_state="$3" acting_login="$4" merged_file
|
||||
|
||||
merged_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-pr-review-list.XXXXXX")
|
||||
trap 'rm -f "$merged_file"' RETURN
|
||||
|
||||
gitea_fetch_all "$GITEA_API_BASE/pulls/$pr_number/reviews" "$merged_file" || return 1
|
||||
|
||||
CREATED_REVIEW_ID="$created_id" EXPECTED_STATE="$expected_state" ACTING_LOGIN="$acting_login" \
|
||||
python3 - "$merged_file" <<'PY'
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
try:
|
||||
with open(sys.argv[1], encoding="utf-8") as response:
|
||||
reviews = json.load(response)
|
||||
if not isinstance(reviews, list):
|
||||
raise ValueError("response is not a review list")
|
||||
created_id = int(os.environ["CREATED_REVIEW_ID"])
|
||||
expected_state = os.environ["EXPECTED_STATE"]
|
||||
acting_login = os.environ["ACTING_LOGIN"]
|
||||
match = next(
|
||||
(
|
||||
r for r in reviews
|
||||
if isinstance(r, dict)
|
||||
and r.get("id") == created_id
|
||||
and (r.get("user") or {}).get("login") == acting_login
|
||||
and r.get("state") == expected_state
|
||||
),
|
||||
None,
|
||||
)
|
||||
if match is None:
|
||||
raise ValueError(
|
||||
f"created review id {created_id} is not enumerable in the PR's "
|
||||
"paginated review list under the acting identity/state"
|
||||
)
|
||||
except (OSError, json.JSONDecodeError, KeyError, TypeError, ValueError) as error:
|
||||
print(f"Error: Gitea review enumeration check failed: {error}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
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" \
|
||||
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"]
|
||||
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")
|
||||
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
|
||||
|
||||
gitea_confirm_review_enumerable "$pr_number" "$created_id" "$event" "$acting_login" || return 1
|
||||
|
||||
echo "$created_id"
|
||||
return 0
|
||||
}
|
||||
|
||||
if [[ "$PLATFORM" == "github" ]]; then
|
||||
case $ACTION in
|
||||
approve)
|
||||
@@ -86,124 +539,76 @@ if [[ "$PLATFORM" == "github" ]]; then
|
||||
elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||
case $ACTION in
|
||||
approve)
|
||||
repo=$(get_repo_slug)
|
||||
host=$(get_remote_host)
|
||||
login=$(get_gitea_login_for_host "$host")
|
||||
tea pr approve "$PR_NUMBER" --repo "$repo" --login "$login" ${COMMENT:+--comment "$COMMENT"}
|
||||
echo "Approved Gitea PR #$PR_NUMBER"
|
||||
# A --login override always wins. Otherwise name this host's login
|
||||
# only as a best effort: the login name merely selects a per-login
|
||||
# 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)
|
||||
# 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" || 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)
|
||||
if [[ -z "$COMMENT" ]]; then
|
||||
echo "Error: Comment required for request-changes"
|
||||
exit 1
|
||||
fi
|
||||
repo=$(get_repo_slug)
|
||||
host=$(get_remote_host)
|
||||
login=$(get_gitea_login_for_host "$host")
|
||||
tea pr reject "$PR_NUMBER" --repo "$repo" --login "$login" --comment "$COMMENT"
|
||||
echo "Requested changes on Gitea PR #$PR_NUMBER"
|
||||
# A --login override always wins. Otherwise name this host's login
|
||||
# only as a best effort: the login name merely selects a per-login
|
||||
# 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" || 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)
|
||||
if [[ -z "$COMMENT" ]]; then
|
||||
echo "Error: Comment required"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
host=$(get_remote_host)
|
||||
token=$(get_gitea_token "$host") || {
|
||||
echo "Error: Gitea token not found for comment persistence" >&2
|
||||
# A --login override always wins. Otherwise name this host's login
|
||||
# only as a best effort: the login name merely selects a per-login
|
||||
# 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" || 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
|
||||
}
|
||||
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)"
|
||||
;;
|
||||
*)
|
||||
|
||||
331
packages/mosaic/framework/tools/git/test-issue-comment-readback.sh
Executable file
331
packages/mosaic/framework/tools/git/test-issue-comment-readback.sh
Executable file
@@ -0,0 +1,331 @@
|
||||
#!/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. enumerates the created id in the issue's FULLY PAGINATED comment list,
|
||||
# finding it even when it lands beyond page 1.
|
||||
|
||||
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"
|
||||
OUTPUT_FILE="$WORK_DIR/output.log"
|
||||
CREDENTIALS_FILE="$WORK_DIR/credentials.json"
|
||||
STATE_FILE="$WORK_DIR/comments.json"
|
||||
|
||||
cleanup() {
|
||||
rm -rf "$WORK_DIR"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
mkdir -p "$REPO_DIR" "$BIN_DIR" "$XDG_DIR"
|
||||
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"
|
||||
|
||||
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
|
||||
# GET /issues/7/comments?page=&.. -> paginated listing of persisted state
|
||||
cat > "$BIN_DIR/curl" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
output_file=""
|
||||
method="GET"
|
||||
url=""
|
||||
data=""
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
-o) output_file="$2"; shift 2 ;;
|
||||
-w|-H) 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"
|
||||
|
||||
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
|
||||
write_response 200 "$(ISSUE_COMMENT_LOGIN="$ISSUE_COMMENT_ACTING_LOGIN" 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_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
|
||||
record = {
|
||||
"id": new_id,
|
||||
"body": body,
|
||||
"user": {"login": author},
|
||||
"issue_url": f"https://git.mosaicstack.dev/api/v1/repos/{repo}/issues/7",
|
||||
}
|
||||
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)"
|
||||
elif [[ "$method" == "GET" && "$path" == "$ISSUE_COMMENT_API_BASE/issues/7/comments" ]]; then
|
||||
result=$(ISSUE_COMMENT_QUERY="$query" python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
from urllib.parse import parse_qs
|
||||
|
||||
state_path = os.environ["ISSUE_COMMENT_STATE"]
|
||||
params = parse_qs(os.environ["ISSUE_COMMENT_QUERY"])
|
||||
limit = int(params.get("limit", ["50"])[0])
|
||||
page = int(params.get("page", ["1"])[0])
|
||||
with open(state_path, encoding="utf-8") as handle:
|
||||
comments = json.load(handle)
|
||||
start = (page - 1) * limit
|
||||
print("200")
|
||||
print(json.dumps(comments[start:start + limit]))
|
||||
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"]
|
||||
issue_url = f"https://git.mosaicstack.dev/api/v1/repos/{repo}/issues/7"
|
||||
|
||||
if mode == "fresh-success":
|
||||
# 50 pre-existing comments fill page 1 (limit 50); the comment this run
|
||||
# creates becomes id 51 and lands ALONE on page 2, exercising >page-1
|
||||
# pagination in the enumeration check.
|
||||
comments = [
|
||||
{"id": i, "body": f"prior {i}", "user": {"login": acting}, "issue_url": issue_url}
|
||||
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 = [
|
||||
{"id": 55, "body": body, "user": {"login": acting}, "issue_url": issue_url}
|
||||
]
|
||||
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"
|
||||
: > "$TEA_LOG"
|
||||
: > "$CURL_LOG"
|
||||
: > "$OUTPUT_FILE"
|
||||
seed_state "$mode"
|
||||
(
|
||||
cd "$REPO_DIR"
|
||||
PATH="$BIN_DIR:$PATH" \
|
||||
XDG_CONFIG_HOME="$XDG_DIR" \
|
||||
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
|
||||
ISSUE_COMMENT_TEA_LOG="$TEA_LOG" \
|
||||
ISSUE_COMMENT_CURL_LOG="$CURL_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_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
|
||||
}
|
||||
|
||||
# Case 1: a genuine REST create (id 51) is verified end to end via its exact
|
||||
# provider-returned id and enumerated on page 2 of the paginated listing.
|
||||
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"
|
||||
# Enumeration paginated beyond page 1 to find the created comment.
|
||||
grep -q "^GET $API_BASE/issues/7/comments?limit=[0-9]*&page=2$" "$CURL_LOG"
|
||||
|
||||
# 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
|
||||
|
||||
echo "issue-comment.sh REST create + exact-id read-back regression passed"
|
||||
@@ -1,5 +1,23 @@
|
||||
#!/usr/bin/env bash
|
||||
# Regression harness for durable Gitea PR review comments (#812).
|
||||
# Regression harness for pr-review.sh's Gitea review + comment writes (#865,
|
||||
# #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.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
@@ -7,6 +25,11 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-review-gitea-comment}"
|
||||
REPO_DIR="$WORK_DIR/repo"
|
||||
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"
|
||||
CURL_LOG="$WORK_DIR/curl.log"
|
||||
OUTPUT_FILE="$WORK_DIR/output.log"
|
||||
@@ -17,7 +40,11 @@ cleanup() {
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
mkdir -p "$REPO_DIR" "$BIN_DIR"
|
||||
ACTING_LOGIN="review-bot"
|
||||
FOREIGN_LOGIN="other-writer"
|
||||
HEAD_SHA="HEADSHA_FEEDFACE"
|
||||
|
||||
mkdir -p "$REPO_DIR" "$BIN_DIR" "$XDG_DIR" "$STATE_DIR"
|
||||
git -C "$REPO_DIR" init -q
|
||||
git -C "$REPO_DIR" remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git
|
||||
|
||||
@@ -40,6 +67,9 @@ with open(sys.argv[1], "w", encoding="utf-8") as credentials:
|
||||
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'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
@@ -47,33 +77,17 @@ set -euo pipefail
|
||||
printf '%s\n' "$*" >> "$PR_REVIEW_TEA_LOG"
|
||||
|
||||
if [[ "$*" == "login list --output json" ]]; then
|
||||
printf '%s\n' '[{"name":"mosaicstack","url":"https://git.mosaicstack.dev"}]'
|
||||
printf '[{"name":"mosaicstack","url":"%s"}]\n' "$PR_REVIEW_LOGIN_URL"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
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 95
|
||||
;;
|
||||
esac
|
||||
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 review/comment
|
||||
# state.
|
||||
cat > "$BIN_DIR/curl" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
@@ -84,34 +98,19 @@ payload=""
|
||||
url=""
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
-o)
|
||||
output_file="$2"
|
||||
shift 2
|
||||
;;
|
||||
-w|-H)
|
||||
shift 2
|
||||
;;
|
||||
-X)
|
||||
method="$2"
|
||||
shift 2
|
||||
;;
|
||||
-d|--data)
|
||||
payload="$2"
|
||||
shift 2
|
||||
;;
|
||||
-s|-S|-sS)
|
||||
shift
|
||||
;;
|
||||
http://*|https://*)
|
||||
url="$1"
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
shift
|
||||
;;
|
||||
-o) output_file="$2"; shift 2 ;;
|
||||
-w|-H) shift 2 ;;
|
||||
-X) method="$2"; shift 2 ;;
|
||||
-d|--data) payload="$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" >> "$PR_REVIEW_CURL_LOG"
|
||||
|
||||
write_response() {
|
||||
@@ -121,116 +120,352 @@ write_response() {
|
||||
printf '%s' "$status"
|
||||
}
|
||||
|
||||
case "${PR_REVIEW_TEST_MODE:-}" in
|
||||
legacy-fallback|write-transport-failure)
|
||||
echo "simulated transport failure" >&2
|
||||
exit 7
|
||||
;;
|
||||
write-http-failure)
|
||||
write_response 500 '{"message":"simulated rejection"}'
|
||||
;;
|
||||
comment-success|http-success|prefix-success|subpath-success|port-success|scp-ssh-success|url-ssh-success|readback-failure)
|
||||
if [[ "$method" == "POST" && "$url" == "$PR_REVIEW_EXPECTED_API_BASE/issues/123/comments" ]]; then
|
||||
PR_REVIEW_PAYLOAD="$payload" python3 - <<'PY'
|
||||
emit() {
|
||||
# Split a two-line "status\n<json body>" python result into the response.
|
||||
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
|
||||
write_response 200 "$(PR_REVIEW_LOGIN="$PR_REVIEW_ACTING_LOGIN" python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
|
||||
assert json.loads(os.environ["PR_REVIEW_PAYLOAD"]) == {"body": os.environ["PR_REVIEW_EXPECTED_BODY"]}
|
||||
print(json.dumps({"login": os.environ["PR_REVIEW_LOGIN"]}))
|
||||
PY
|
||||
response=$(python3 - <<'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({"id": 456, "body": os.environ["PR_REVIEW_EXPECTED_BODY"]}))
|
||||
print(json.dumps({"head": {"sha": os.environ["PR_REVIEW_HEAD_SHA"]}}))
|
||||
PY
|
||||
)
|
||||
write_response 201 "$response"
|
||||
elif [[ "$method" == "GET" && "$url" == "$PR_REVIEW_EXPECTED_API_BASE/issues/comments/456" ]]; then
|
||||
if [[ "$PR_REVIEW_TEST_MODE" == "readback-failure" ]]; then
|
||||
body="different-body"
|
||||
else
|
||||
body="$PR_REVIEW_EXPECTED_BODY"
|
||||
fi
|
||||
response=$(PR_REVIEW_BODY="$body" python3 - <<'PY'
|
||||
)"
|
||||
elif [[ "$method" == "POST" && "$path" == "$PR_REVIEW_EXPECTED_API_BASE/pulls/123/reviews" ]]; then
|
||||
printf '%s' "$payload" > "$PR_REVIEW_SUBMIT_PAYLOAD"
|
||||
emit "$(PR_REVIEW_PAYLOAD="$payload" python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
|
||||
print(json.dumps({
|
||||
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)
|
||||
|
||||
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" == "GET" && "$path" == "$PR_REVIEW_EXPECTED_API_BASE/pulls/123/reviews" ]]; then
|
||||
emit "$(PR_REVIEW_QUERY="$query" python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
from urllib.parse import parse_qs
|
||||
|
||||
state_path = os.environ["PR_REVIEW_REVIEWS"]
|
||||
params = parse_qs(os.environ["PR_REVIEW_QUERY"])
|
||||
limit = int(params.get("limit", ["50"])[0])
|
||||
page = int(params.get("page", ["1"])[0])
|
||||
with open(state_path, encoding="utf-8") as handle:
|
||||
reviews = json.load(handle)
|
||||
start = (page - 1) * limit
|
||||
print("200")
|
||||
print(json.dumps(reviews[start:start + limit]))
|
||||
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
|
||||
exit 7
|
||||
;;
|
||||
write-http-failure)
|
||||
write_response 500 '{"message":"simulated rejection"}'
|
||||
;;
|
||||
*)
|
||||
emit "$(PR_REVIEW_PAYLOAD="$payload" python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
|
||||
state_path = os.environ["PR_REVIEW_COMMENTS"]
|
||||
acting = os.environ["PR_REVIEW_ACTING_LOGIN"]
|
||||
base = os.environ["PR_REVIEW_EXPECTED_API_BASE"]
|
||||
body = json.loads(os.environ["PR_REVIEW_PAYLOAD"]).get("body")
|
||||
record = {
|
||||
"id": 456,
|
||||
"body": os.environ["PR_REVIEW_BODY"],
|
||||
"issue_url": os.environ["PR_REVIEW_EXPECTED_API_BASE"] + "/issues/123",
|
||||
}))
|
||||
"body": body,
|
||||
"user": {"login": acting},
|
||||
"issue_url": f"{base}/issues/123",
|
||||
}
|
||||
with open(state_path, "w", encoding="utf-8") as handle:
|
||||
json.dump([record], handle)
|
||||
print("201")
|
||||
print(json.dumps(record))
|
||||
PY
|
||||
)
|
||||
write_response 200 "$response"
|
||||
else
|
||||
echo "Unexpected curl request: $method $url" >&2
|
||||
exit 97
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
exit 98
|
||||
;;
|
||||
esac
|
||||
)"
|
||||
;;
|
||||
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
|
||||
echo "Unexpected curl request: $method $url" >&2
|
||||
exit 97
|
||||
fi
|
||||
SH
|
||||
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 == "paginated-approve":
|
||||
# 50 pre-existing reviews fill page 1 (limit 50); the review this run submits
|
||||
# becomes id 51 and lands ALONE on page 2, exercising >page-1 pagination in
|
||||
# the enumeration check.
|
||||
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() {
|
||||
local mode="$1" action="$2" comment="${3:-}"
|
||||
local configured_url="${4:-https://git.mosaicstack.dev}"
|
||||
local remote_url="${5:-https://git.mosaicstack.dev/mosaicstack/stack.git}"
|
||||
local expected_repo="${6:-mosaicstack/stack}"
|
||||
local expected_api_base="${configured_url%/}/api/v1/repos/$expected_repo"
|
||||
local expected_api_root="${configured_url%/}/api/v1"
|
||||
git -C "$REPO_DIR" remote set-url origin "$remote_url"
|
||||
write_credentials "$configured_url"
|
||||
: > "$TEA_LOG"
|
||||
: > "$CURL_LOG"
|
||||
: > "$OUTPUT_FILE"
|
||||
seed_state "$mode"
|
||||
(
|
||||
cd "$REPO_DIR"
|
||||
PATH="$BIN_DIR:$PATH" \
|
||||
XDG_CONFIG_HOME="$XDG_DIR" \
|
||||
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
|
||||
PR_REVIEW_TEA_LOG="$TEA_LOG" \
|
||||
PR_REVIEW_LOGIN_URL="${configured_url%/}" \
|
||||
PR_REVIEW_CURL_LOG="$CURL_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_EXPECTED_BODY="$comment" \
|
||||
PR_REVIEW_EXPECTED_API_BASE="$expected_api_base" \
|
||||
PR_REVIEW_API_ROOT="$expected_api_root" \
|
||||
PR_REVIEW_HEAD_SHA="$HEAD_SHA" \
|
||||
PR_REVIEW_ACTING_LOGIN="$ACTING_LOGIN" \
|
||||
PR_REVIEW_FOREIGN_LOGIN="$FOREIGN_LOGIN" \
|
||||
"$SCRIPT_DIR/pr-review.sh" -n 123 -a "$action" ${comment:+-c "$comment"}
|
||||
) > "$OUTPUT_FILE" 2>&1
|
||||
}
|
||||
|
||||
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
|
||||
grep -q '^pr approve 123 --repo mosaicstack/stack --login mosaicstack$' "$TEA_LOG"
|
||||
grep -q 'Approved Gitea PR #123' "$OUTPUT_FILE"
|
||||
grep -q 'Approved and verified Gitea PR #123 (review ID 101)' "$OUTPUT_FILE"
|
||||
grep -q '^GET https://git.mosaicstack.dev/api/v1/user$' "$CURL_LOG"
|
||||
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"
|
||||
grep -q '^GET https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/pulls/123/reviews?limit=[0-9]*&page=1$' "$CURL_LOG"
|
||||
assert_no_tea_write
|
||||
# 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
|
||||
|
||||
run_review request-changes request-changes changes-required
|
||||
grep -q '^pr reject 123 --repo mosaicstack/stack --login mosaicstack --comment changes-required$' "$TEA_LOG"
|
||||
grep -q 'Requested changes on Gitea PR #123' "$OUTPUT_FILE"
|
||||
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
|
||||
|
||||
if run_review legacy-fallback comment durable-body; then
|
||||
echo "The old nonexistent tea pr comment fallback returned success" >&2
|
||||
# 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 '^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
|
||||
if grep -q 'Approved and verified' "$OUTPUT_FILE"; then
|
||||
echo "FAIL: read-back did not enforce acting-identity authorship" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 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 that lands beyond page 1 of the reviews list
|
||||
# must still be found by the fully-paginating enumeration check.
|
||||
run_review paginated-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"
|
||||
grep -q '^GET https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/pulls/123/reviews?limit=[0-9]*&page=2$' "$CURL_LOG"
|
||||
|
||||
# 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
|
||||
grep -q 'Requested changes and verified on Gitea PR #123 (review ID 101)' "$OUTPUT_FILE"
|
||||
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"
|
||||
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"))
|
||||
assert payload["event"] == "REQUEST_CHANGES", payload
|
||||
assert payload["body"] == os.environ["PR_REVIEW_EXPECTED_BODY"], payload
|
||||
PY
|
||||
assert_no_tea_write
|
||||
|
||||
# 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'
|
||||
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 '^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"
|
||||
if [[ -s "$TEA_LOG" ]]; then
|
||||
echo "REST comment path unexpectedly invoked tea" >&2
|
||||
cat "$TEA_LOG" >&2
|
||||
exit 1
|
||||
fi
|
||||
assert_no_tea_write
|
||||
|
||||
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"
|
||||
@@ -258,6 +493,17 @@ 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
|
||||
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
|
||||
echo "Expected provider transport failure to return nonzero" >&2
|
||||
exit 1
|
||||
@@ -275,4 +521,4 @@ if grep -q 'Added and verified comment' "$OUTPUT_FILE"; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "pr-review.sh durable Gitea comment regression passed"
|
||||
echo "pr-review.sh REST review + comment create/read-back regression passed"
|
||||
|
||||
@@ -50,6 +50,21 @@ if ! [[ "$FILE_PATH" =~ \.(ts|tsx|js|jsx|mjs|cjs)$ ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Deps preflight (#856): this hook is the common gate-entry seam the delivery
|
||||
# cycle invokes on every Edit/Write/MultiEdit — it fires before any pnpm-based
|
||||
# gate (test/lint/typecheck/format:check) runs against the edited file. In a
|
||||
# freshly created git worktree (pnpm workspaces do NOT share node_modules
|
||||
# across worktrees), node_modules/.bin is empty until `pnpm install` has run,
|
||||
# so gate binaries (tsc/eslint/prettier/vitest) fail with a raw, illegible
|
||||
# `sh: 1: <tool>: not found` that is indistinguishable from a real failure.
|
||||
# Fail legibly here instead, before that raw error has a chance to surface.
|
||||
BIN_DIR="$PROJECT_ROOT/node_modules/.bin"
|
||||
if [ ! -d "$BIN_DIR" ] || [ -z "$(ls -A "$BIN_DIR" 2>/dev/null)" ]; then
|
||||
echo "deps not installed — run pnpm install" >&2
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] [ERROR] deps not installed — run pnpm install ($BIN_DIR is missing or empty)" >> "$LOG_FILE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Call the main QA handler with extracted parameters
|
||||
if [ -f ~/.config/mosaic/tools/qa/qa-hook-handler.sh ]; then
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Calling QA handler for $FILE_PATH" >> "$LOG_FILE"
|
||||
|
||||
116
packages/mosaic/framework/tools/qa/test-deps-preflight.sh
Executable file
116
packages/mosaic/framework/tools/qa/test-deps-preflight.sh
Executable file
@@ -0,0 +1,116 @@
|
||||
#!/usr/bin/env bash
|
||||
# Regression harness for #856: worker git-worktrees under a fresh `git worktree
|
||||
# add` have no node_modules until `pnpm install` runs (pnpm workspaces do NOT
|
||||
# share node_modules across worktrees). Before the fix, the gate-entry seam
|
||||
# (qa-hook-stdin.sh, registered as the PostToolUse hook for every Edit/Write/
|
||||
# MultiEdit in runtime/claude/settings.json) silently let a raw
|
||||
# `sh: 1: <tool>: not found` surface from any downstream gate invocation —
|
||||
# indistinguishable from a real test/lint failure (false-red).
|
||||
#
|
||||
# Asserts:
|
||||
# 1. RED (documented): a completely fresh worktree with no node_modules/.bin
|
||||
# at all produces the raw "not found" for a gate binary — this is the
|
||||
# defect the fix prevents from reaching the operator un-annotated.
|
||||
# 2. With node_modules/.bin missing entirely, the seam exits nonzero with
|
||||
# the legible sentinel "deps not installed — run pnpm install" instead
|
||||
# of silently proceeding (exit 0) into a would-be raw not-found.
|
||||
# 3. With node_modules/.bin present but empty, same legible-sentinel
|
||||
# behavior (covers `git worktree add` immediately followed by an
|
||||
# as-yet-incomplete/interrupted install).
|
||||
# 4. Once node_modules/.bin is populated (post `pnpm install`), the seam
|
||||
# proceeds normally (exit 0) — the preflight does not false-positive.
|
||||
# 5. Non-JS/TS files are unaffected (existing skip behavior preserved).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
HOOK="$SCRIPT_DIR/qa-hook-stdin.sh"
|
||||
|
||||
TMP_DIR=$(mktemp -d)
|
||||
trap 'rm -rf "$TMP_DIR"' EXIT
|
||||
|
||||
fail=0
|
||||
|
||||
fail_msg() {
|
||||
echo "FAIL: $*" >&2
|
||||
fail=1
|
||||
}
|
||||
|
||||
run_hook() {
|
||||
local file_path="$1"
|
||||
printf '{"tool_name":"Edit","tool_input":{"file_path":"%s"}}' "$file_path" | "$HOOK"
|
||||
}
|
||||
|
||||
make_fixture_repo() {
|
||||
local dir="$1"
|
||||
mkdir -p "$dir"
|
||||
git -C "$dir" init -q .
|
||||
git -C "$dir" -c user.email=fixture@test -c user.name=fixture commit -q --allow-empty -m init
|
||||
}
|
||||
|
||||
# --- Scenario 1: RED — document the pre-fix raw not-found a gate hits when
|
||||
# node_modules/.bin is entirely absent (this is what the preflight now
|
||||
# intercepts before any gate command runs).
|
||||
RED_DIR="$TMP_DIR/red-fixture"
|
||||
make_fixture_repo "$RED_DIR"
|
||||
RED_OUTPUT=$(PATH="/usr/bin:/bin" sh -c 'tsc --noEmit' 2>&1) && RED_STATUS=0 || RED_STATUS=$?
|
||||
case "$RED_OUTPUT" in
|
||||
*"not found"*) ;;
|
||||
*) fail_msg "expected the raw un-preflighted invocation to demonstrate 'not found'; got: $RED_OUTPUT" ;;
|
||||
esac
|
||||
[[ "$RED_STATUS" -ne 0 ]] || fail_msg "expected raw invocation without deps installed to fail"
|
||||
|
||||
# --- Scenario 2: node_modules/.bin missing entirely -> legible sentinel, nonzero.
|
||||
MISSING_DIR="$TMP_DIR/missing-bin"
|
||||
make_fixture_repo "$MISSING_DIR"
|
||||
echo "console.log(1)" > "$MISSING_DIR/x.ts"
|
||||
OUTPUT=$(cd "$MISSING_DIR" && run_hook "$MISSING_DIR/x.ts" 2>&1) && STATUS=0 || STATUS=$?
|
||||
[[ "$STATUS" -ne 0 ]] || fail_msg "missing node_modules/.bin: expected nonzero exit, got 0"
|
||||
case "$OUTPUT" in
|
||||
*"deps not installed"*"pnpm install"*) ;;
|
||||
*) fail_msg "missing node_modules/.bin: expected legible sentinel, got: $OUTPUT" ;;
|
||||
esac
|
||||
|
||||
# --- Scenario 3: node_modules/.bin present but empty -> legible sentinel, nonzero.
|
||||
EMPTY_DIR="$TMP_DIR/empty-bin"
|
||||
make_fixture_repo "$EMPTY_DIR"
|
||||
mkdir -p "$EMPTY_DIR/node_modules/.bin"
|
||||
echo "console.log(1)" > "$EMPTY_DIR/x.ts"
|
||||
OUTPUT=$(cd "$EMPTY_DIR" && run_hook "$EMPTY_DIR/x.ts" 2>&1) && STATUS=0 || STATUS=$?
|
||||
[[ "$STATUS" -ne 0 ]] || fail_msg "empty node_modules/.bin: expected nonzero exit, got 0"
|
||||
case "$OUTPUT" in
|
||||
*"deps not installed"*"pnpm install"*) ;;
|
||||
*) fail_msg "empty node_modules/.bin: expected legible sentinel, got: $OUTPUT" ;;
|
||||
esac
|
||||
|
||||
# --- Scenario 4: node_modules/.bin populated (post `pnpm install`) -> proceeds normally.
|
||||
OK_DIR="$TMP_DIR/installed-bin"
|
||||
make_fixture_repo "$OK_DIR"
|
||||
mkdir -p "$OK_DIR/node_modules/.bin"
|
||||
printf '#!/bin/sh\necho ok\n' > "$OK_DIR/node_modules/.bin/tsc"
|
||||
chmod +x "$OK_DIR/node_modules/.bin/tsc"
|
||||
echo "console.log(1)" > "$OK_DIR/x.ts"
|
||||
OUTPUT=$(cd "$OK_DIR" && run_hook "$OK_DIR/x.ts" 2>&1) && STATUS=0 || STATUS=$?
|
||||
[[ "$STATUS" -eq 0 ]] || fail_msg "populated node_modules/.bin: expected exit 0, got $STATUS ($OUTPUT)"
|
||||
case "$OUTPUT" in
|
||||
*"deps not installed"*) fail_msg "populated node_modules/.bin: unexpected sentinel fired: $OUTPUT" ;;
|
||||
*) ;;
|
||||
esac
|
||||
|
||||
# --- Scenario 5: non-JS/TS files are unaffected by the preflight (still
|
||||
# skipped before the deps check, regardless of node_modules state).
|
||||
NONJS_DIR="$TMP_DIR/nonjs"
|
||||
make_fixture_repo "$NONJS_DIR"
|
||||
echo "# doc" > "$NONJS_DIR/README.md"
|
||||
OUTPUT=$(cd "$NONJS_DIR" && run_hook "$NONJS_DIR/README.md" 2>&1) && STATUS=0 || STATUS=$?
|
||||
[[ "$STATUS" -eq 0 ]] || fail_msg "non-JS/TS file: expected exit 0 (skip), got $STATUS ($OUTPUT)"
|
||||
case "$OUTPUT" in
|
||||
*"deps not installed"*) fail_msg "non-JS/TS file: preflight incorrectly fired: $OUTPUT" ;;
|
||||
*) ;;
|
||||
esac
|
||||
|
||||
if [[ "$fail" -eq 0 ]]; then
|
||||
echo "deps-preflight regression passed (5/5 scenarios)"
|
||||
fi
|
||||
|
||||
exit "$fail"
|
||||
@@ -25,7 +25,7 @@
|
||||
"lint": "eslint src",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"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"
|
||||
"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"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mosaicstack/brain": "workspace:*",
|
||||
|
||||
@@ -31,17 +31,26 @@ PI_EXTENSION = FRAMEWORK / "runtime/pi/mosaic-extension.ts"
|
||||
|
||||
|
||||
def request(socket_path: Path, value: dict[str, object]) -> dict[str, object]:
|
||||
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as connection:
|
||||
connection.settimeout(3.0)
|
||||
connection.connect(str(socket_path))
|
||||
connection.sendall((json.dumps(value, separators=(",", ":")) + "\n").encode())
|
||||
connection.shutdown(socket.SHUT_WR)
|
||||
response = bytearray()
|
||||
while True:
|
||||
chunk = connection.recv(4096)
|
||||
if not chunk:
|
||||
break
|
||||
response.extend(chunk)
|
||||
deadline = time.monotonic() + 5.0
|
||||
while True:
|
||||
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as connection:
|
||||
connection.settimeout(3.0)
|
||||
try:
|
||||
connection.connect(str(socket_path))
|
||||
except ConnectionRefusedError:
|
||||
if time.monotonic() >= deadline:
|
||||
raise
|
||||
time.sleep(0.02)
|
||||
continue
|
||||
connection.sendall((json.dumps(value, separators=(",", ":")) + "\n").encode())
|
||||
connection.shutdown(socket.SHUT_WR)
|
||||
response = bytearray()
|
||||
while True:
|
||||
chunk = connection.recv(4096)
|
||||
if not chunk:
|
||||
break
|
||||
response.extend(chunk)
|
||||
break
|
||||
if not response.endswith(b"\n") or response.count(b"\n") != 1:
|
||||
raise AssertionError(f"unframed broker response: {bytes(response)!r}")
|
||||
reply = json.loads(response[:-1])
|
||||
|
||||
@@ -661,13 +661,27 @@ describe('whole mutator-class lease gate', () => {
|
||||
test('observer revocation and monotonic TTL expiry deny the next mutator', async () => {
|
||||
const { socket } = await startBroker();
|
||||
const sessionId = await register(socket);
|
||||
const pending = await beginVerification(socket, sessionId, 'claude', 1, 1);
|
||||
await promote(socket, sessionId, pending.receipt_challenge!);
|
||||
|
||||
// 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!);
|
||||
expect(await authorize(socket, sessionId, 'claude', 'Bash')).toMatchObject({
|
||||
ok: true,
|
||||
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));
|
||||
expect(await authorize(socket, sessionId, 'claude', 'Bash')).toMatchObject({
|
||||
ok: false,
|
||||
@@ -675,7 +689,7 @@ describe('whole mutator-class lease gate', () => {
|
||||
decision: 'deny',
|
||||
});
|
||||
|
||||
const refreshed = await beginVerification(socket, sessionId, 'claude', 1, 300, 2);
|
||||
const refreshed = await beginVerification(socket, sessionId, 'claude', 1, 300, 3);
|
||||
await promote(socket, sessionId, refreshed.receipt_challenge!);
|
||||
expect(
|
||||
await request(socket, {
|
||||
|
||||
@@ -217,12 +217,22 @@ git fetch origin
|
||||
mkdir -p ~/src/${projectName}-worktrees
|
||||
git worktree add ~/src/${projectName}-worktrees/<task-slug> -b <branch-name> origin/main
|
||||
cd ~/src/${projectName}-worktrees/<task-slug>
|
||||
pnpm install --frozen-lockfile --prefer-offline
|
||||
# ... all work happens here ...
|
||||
git push origin <branch-name>
|
||||
cd ~/src/${projectName} && git worktree remove ~/src/${projectName}-worktrees/<task-slug>
|
||||
\`\`\`
|
||||
|
||||
Worktrees path: \`~/src/<repo>-worktrees/<task-slug>\` — NEVER use /tmp.`);
|
||||
Worktrees path: \`~/src/<repo>-worktrees/<task-slug>\` — NEVER use /tmp.
|
||||
|
||||
\`pnpm install --frozen-lockfile --prefer-offline\` MUST run immediately after
|
||||
\`git worktree add\`/\`cd\`, BEFORE any gate (\`pnpm test\`/\`lint\`/\`typecheck\`/\`format:check\`)
|
||||
is invoked. pnpm workspaces do NOT share \`node_modules\` across separate git
|
||||
worktrees — a fresh worktree has an empty \`node_modules/.bin\`, so every gate
|
||||
binary (\`tsc\`/\`eslint\`/\`prettier\`/\`vitest\`) fails \`sh: 1: <tool>: not found\`
|
||||
until deps are installed. That failure is indistinguishable from a real
|
||||
test/lint failure — a false-red gate. Never skip this step and never reorder
|
||||
it after the first gate invocation.`);
|
||||
|
||||
// 6. Completion gates
|
||||
sections.push(`# Completion Gates — ENFORCED
|
||||
|
||||
50
skills/glpi-create/SKILL.md
Normal file
50
skills/glpi-create/SKILL.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# 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.
|
||||
56
skills/glpi-followup/SKILL.md
Normal file
56
skills/glpi-followup/SKILL.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# 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.
|
||||
57
skills/glpi-list/SKILL.md
Normal file
57
skills/glpi-list/SKILL.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# 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).
|
||||
96
skills/glpi-solve/SKILL.md
Normal file
96
skills/glpi-solve/SKILL.md
Normal file
@@ -0,0 +1,96 @@
|
||||
# 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 2–3.
|
||||
|
||||
## 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.
|
||||
62
skills/glpi-sweep/SKILL.md
Normal file
62
skills/glpi-sweep/SKILL.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user