Compare commits
16 Commits
docs/758-l
...
fix/865-te
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2bb3ac4549 | ||
|
|
6168f9ac86 | ||
|
|
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-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-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-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 |
|
| 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
|
## Thin-core prompt diet (#528) — feat/contract-thin-core
|
||||||
|
|||||||
58
docs/scratchpads/812-pr-review-comment.md
Normal file
58
docs/scratchpads/812-pr-review-comment.md
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
# Issue #812 — durable Gitea PR review comments
|
||||||
|
|
||||||
|
- **Lane:** ms-812
|
||||||
|
- **Branch:** `fix/812-pr-review-comment`
|
||||||
|
- **Issue:** mosaicstack/stack#812
|
||||||
|
- **Budget:** 15K working estimate; single focused shell-wrapper/test/docs change.
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Make the Gitea `comment` action in `packages/mosaic/framework/tools/git/pr-review.sh` use the supported Gitea comments REST API and report success only after provider read-back verifies the created comment against the intended repository, PR, and exact body.
|
||||||
|
|
||||||
|
## Plan
|
||||||
|
|
||||||
|
1. Add and commit a failing shell regression harness before production changes.
|
||||||
|
2. Verify RED against the nonexistent `tea pr comment` fallback false-positive.
|
||||||
|
3. Implement the minimal supported write plus ID-based provider read-back.
|
||||||
|
4. Document that wrapper write output is not durable provenance until read-back succeeds.
|
||||||
|
5. Run focused regression tests, touched-package tests, and repository quality gates.
|
||||||
|
6. Remediate review findings, queue-guard, and push for coordinator-owned independent review. Do not open or merge a PR.
|
||||||
|
|
||||||
|
## Progress checkpoints
|
||||||
|
|
||||||
|
- [x] RED regression committed and reported to mosaic-100 (rebased commit `770e3f57`)
|
||||||
|
- [x] Initial minimal fix implemented (rebased commit `ea7f8c57`)
|
||||||
|
- [x] Rebased cleanly onto main `627cf2bb387f7c84a532d88819903a7679ce0d72`
|
||||||
|
- [x] Codex blocker remediated by replacing unsupported `tea api` with authenticated REST write/read-back
|
||||||
|
- [x] Focused, package, and repository gates green
|
||||||
|
- [ ] Coordinator-owned independent review pending after push
|
||||||
|
- [x] No PR opened; no self-review or self-merge
|
||||||
|
|
||||||
|
## Tests run
|
||||||
|
|
||||||
|
- RED after rebase: the regression harness failed against `origin/main` with status 1 after reproducing the old `tea pr comment` zero-exit fallback and false success echo.
|
||||||
|
- GREEN at resumed head: the same harness passed with REST POST 201 plus GET 200 read-back.
|
||||||
|
- All `packages/mosaic/framework/tools/git/test-*.sh` harnesses passed.
|
||||||
|
- `shellcheck -x` passed for the changed scripts; `bash -n` passed.
|
||||||
|
- Manifest resolver returned `framework` for `tools/git/test-pr-review-gitea-comment.sh`.
|
||||||
|
- `pnpm test` passed (43/43 Turbo tasks; Mosaic 75 files/1434 tests; Gateway 56 files/628 tests plus documented skips).
|
||||||
|
- `pnpm typecheck` passed (42/42 tasks), `pnpm lint` passed (23/23), and `pnpm format:check` passed.
|
||||||
|
- Firewall checks found no user-home paths or operator identities in changed shipped files; no token value is logged or echoed.
|
||||||
|
|
||||||
|
## Risks / blockers
|
||||||
|
|
||||||
|
- No active implementation blocker. #789 reached terminal merged state and the coordination hold was lifted.
|
||||||
|
- Review round 1 found one portability blocker: the API base reconstructed `https://$host` and discarded configured schemes/path prefixes.
|
||||||
|
- Review round 2 found a second subpath portability blocker: clone-derived `get_repo_slug` retained the deployment prefix, duplicating it under `/api/v1/repos/`.
|
||||||
|
- Round 3 resolves owner/repo relative to the configured Gitea base path for HTTP(S) clones while preserving root-mounted and SSH clone forms. Host matching now compares non-default ports consistently.
|
||||||
|
- REST transport failures, non-201 writes, malformed/missing created IDs, non-200 read-backs, and read-back mismatches all fail closed.
|
||||||
|
- Existing approve/request-changes behavior remains covered.
|
||||||
|
- Independent exact-head re-review remains coordinator-owned.
|
||||||
|
|
||||||
|
## Final verification evidence
|
||||||
|
|
||||||
|
- URL-portability regression was RED before remediation at the new `http://git.mosaicstack.dev` case and GREEN afterward.
|
||||||
|
- Round-3 genuine subpath regression was RED against round-2 head `1b190201` and GREEN after the fix: `https://git.example/gitea/owner/repo.git` maps to API repository `owner/repo` under configured base `/gitea`.
|
||||||
|
- Regression coverage verifies POST and read-back GET for root-mounted HTTP(S), path-prefixed HTTP(S), non-default HTTP port, scp-style SSH, and `ssh://` clone forms.
|
||||||
|
- Focused shell checks, all git-wrapper harnesses, and full repository test/typecheck/lint/format gates passed after remediation.
|
||||||
|
- Branch will be force-pushed with lease for coordinator re-verification; no PR opened.
|
||||||
29
packages/mosaic/framework/tools/git/README.md
Normal file
29
packages/mosaic/framework/tools/git/README.md
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# Git provider wrappers
|
||||||
|
|
||||||
|
These scripts provide host-aware GitHub and Gitea issue, pull-request, milestone, and CI operations.
|
||||||
|
|
||||||
|
## Durable review provenance
|
||||||
|
|
||||||
|
A successful provider write command—or a wrapper message based only on that command's exit code—is **not** durable review provenance. Review comments, approvals, and change requests count as durable provenance only after the wrapper reads the created provider record back and verifies that it was created by _this_ write.
|
||||||
|
|
||||||
|
**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.
|
||||||
|
|
||||||
|
**Exact-id read-back is the sole authority.** Verification is a direct `GET` of the one record the create returned; there is no follow-up list enumeration. An earlier redundant pass that re-listed the record's page (`?limit=&page=1,2,…`) was removed: server-capped page sizes and list-pagination quirks made it a false-failure source (a durable, exact-id-verified record could be missed by a non-exhaustive enumeration), and it added nothing over the authoritative exact-id `GET`.
|
||||||
|
|
||||||
|
## `tea` invocation notes (Gitea)
|
||||||
|
|
||||||
|
- tea v0.11.1 has **no `comment` subcommand under `tea pr` or `tea issue`** — the `tea pr comment` / `tea issue comment` forms don't error, they silently fall through to a no-op and still exit 0, producing a false-success write (#865). tea's write subcommands (`tea comment`, `tea pr approve`/`reject`) also cannot report the id of the record they create, so their exit code cannot prove a durable write. These wrappers therefore do **not** write reviews or comments through `tea` at all; they use direct Gitea REST `POST`s that return the created record's id (see "Durable review provenance" above). `tea` is consulted only to enumerate the login list for host→login resolution.
|
||||||
|
- Because the review body is carried in the `POST …/reviews` submit itself, there is no separate detached review comment, and the historical `tea pr approve`/`reject` trailing-positional-argument vs. nonexistent `--comment`/`-comment` flag hazard (#835) no longer applies to these wrappers — no review comment is ever passed to `tea`.
|
||||||
|
|
||||||
|
### `--login` override
|
||||||
|
|
||||||
|
Both `pr-review.sh` and `issue-comment.sh` accept an optional `--login <name>` flag that overrides the automatically detected Gitea login for that single invocation. The override selects **which credential the REST write, the `/user` identity lookup, and the read-back all use** — its token is resolved from the tea config for that login name (`get_gitea_token_for_login`), falling back to the repo host's credential when no login is named. The resolved login is **host-bound**: the login's configured URL host must match the repo remote's host, so a login name shared across hosts (or an override configured for a different Gitea) can never send one host's credential to another — a host mismatch fails closed rather than leaking a cross-host token. Resolving the acting identity and the read-back from the _same_ login that performs the write is essential: a write performed under an overridden login must be verified against that login's identity, not the host default's. Callers who need a different login than the host default should pass `--login <reviewer-login>`.
|
||||||
|
|
||||||
|
As a durable successor to this mechanism, consider giving each reviewer/approver slot its own dedicated Gitea login credential, so that author≠reviewer holds at the credential level rather than relying on wrapper-level `--login` bookkeeping. This is a recommendation for future hardening, not something implemented by this flag.
|
||||||
@@ -81,7 +81,32 @@ get_repo_slug() {
|
|||||||
gitea_url_matches_host() {
|
gitea_url_matches_host() {
|
||||||
local url="${1:-}" host="${2:-}"
|
local url="${1:-}" host="${2:-}"
|
||||||
[[ -n "$url" && -n "$host" ]] || return 1
|
[[ -n "$url" && -n "$host" ]] || return 1
|
||||||
[[ "${url%/}" == "https://$host" || "${url%/}" == "http://$host" || "${url%/}" == *"//$host" ]]
|
python3 - "$url" "$host" <<'PY'
|
||||||
|
import sys
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
url, remote_host = sys.argv[1:]
|
||||||
|
configured = urlparse(url)
|
||||||
|
remote = urlparse(f"//{remote_host}")
|
||||||
|
if configured.scheme not in {"http", "https"} or configured.hostname != remote.hostname:
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
# 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
|
||||||
}
|
}
|
||||||
|
|
||||||
get_gitea_service_for_host() {
|
get_gitea_service_for_host() {
|
||||||
@@ -347,6 +372,47 @@ get_gitea_api_host_for_repo_override() {
|
|||||||
get_host_from_url "${GITEA_URL:-}"
|
get_host_from_url "${GITEA_URL:-}"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Resolve owner/repo relative to a configured Gitea base URL. HTTP(S) clone
|
||||||
|
# URLs can include the deployment prefix (for example /gitea/owner/repo.git),
|
||||||
|
# but Gitea's /repos API expects only owner/repo. Root-mounted and SSH clone
|
||||||
|
# forms retain their existing owner/repo behavior.
|
||||||
|
get_gitea_repo_slug_for_url() {
|
||||||
|
local configured_url="$1" remote_url
|
||||||
|
remote_url=$(git remote get-url origin 2>/dev/null) || return 1
|
||||||
|
|
||||||
|
if [[ "$remote_url" =~ ^https?:// ]]; then
|
||||||
|
python3 - "$remote_url" "$configured_url" <<'PY'
|
||||||
|
import sys
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
remote = urlparse(sys.argv[1])
|
||||||
|
base = urlparse(sys.argv[2])
|
||||||
|
remote_path = remote.path.strip("/")
|
||||||
|
if remote_path.endswith(".git"):
|
||||||
|
remote_path = remote_path[:-4]
|
||||||
|
base_path = base.path.strip("/")
|
||||||
|
remote_parts = [part for part in remote_path.split("/") if part]
|
||||||
|
base_parts = [part for part in base_path.split("/") if part]
|
||||||
|
|
||||||
|
if base_parts and remote_parts[:len(base_parts)] == base_parts:
|
||||||
|
repo_parts = remote_parts[len(base_parts):]
|
||||||
|
elif len(remote_parts) == 2:
|
||||||
|
# Preserve a root-shaped clone URL when provider API configuration carries
|
||||||
|
# a reverse-proxy prefix separately.
|
||||||
|
repo_parts = remote_parts
|
||||||
|
else:
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
if len(repo_parts) != 2:
|
||||||
|
raise SystemExit(1)
|
||||||
|
print("/".join(repo_parts))
|
||||||
|
PY
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
get_repo_slug
|
||||||
|
}
|
||||||
|
|
||||||
get_gitea_repo_args() {
|
get_gitea_repo_args() {
|
||||||
local repo host login
|
local repo host login
|
||||||
repo=$(get_repo_slug) || return 1
|
repo=$(get_repo_slug) || return 1
|
||||||
@@ -370,6 +436,15 @@ get_remote_host() {
|
|||||||
echo "${host##*@}"
|
echo "${host##*@}"
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
if [[ "$remote_url" =~ ^ssh://([^/]+)/ ]]; then
|
||||||
|
local host="${BASH_REMATCH[1]}"
|
||||||
|
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
|
if [[ "$remote_url" =~ ^git@([^:]+): ]]; then
|
||||||
echo "${BASH_REMATCH[1]}"
|
echo "${BASH_REMATCH[1]}"
|
||||||
return 0
|
return 0
|
||||||
@@ -377,6 +452,51 @@ get_remote_host() {
|
|||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Resolve the configured Gitea base URL for a host from the same credential
|
||||||
|
# source used by get_gitea_token. The scheme and any deployment path prefix are
|
||||||
|
# provider configuration and must not be reconstructed from the git remote.
|
||||||
|
get_gitea_url_for_host() {
|
||||||
|
local host="$1" script_dir cred_loader url
|
||||||
|
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
cred_loader="$script_dir/../_lib/credentials.sh"
|
||||||
|
|
||||||
|
if [[ -f "$cred_loader" ]]; then
|
||||||
|
url=$(
|
||||||
|
# shellcheck source=/dev/null
|
||||||
|
source "$cred_loader"
|
||||||
|
unset GITEA_TOKEN GITEA_URL
|
||||||
|
case "$host" in
|
||||||
|
git.mosaicstack.dev) load_credentials gitea-mosaicstack 2>/dev/null ;;
|
||||||
|
git.uscllc.com) load_credentials gitea-usc 2>/dev/null ;;
|
||||||
|
*)
|
||||||
|
for svc in gitea-mosaicstack gitea-usc; do
|
||||||
|
unset GITEA_TOKEN GITEA_URL
|
||||||
|
load_credentials "$svc" 2>/dev/null || continue
|
||||||
|
if gitea_url_matches_host "${GITEA_URL:-}" "$host"; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
unset GITEA_TOKEN GITEA_URL
|
||||||
|
done
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
if gitea_url_matches_host "${GITEA_URL:-}" "$host"; then
|
||||||
|
printf '%s' "${GITEA_URL%/}"
|
||||||
|
fi
|
||||||
|
)
|
||||||
|
if [[ -n "$url" ]]; then
|
||||||
|
printf '%s\n' "$url"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if gitea_url_matches_host "${GITEA_URL:-}" "$host"; then
|
||||||
|
printf '%s\n' "${GITEA_URL%/}"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
# Resolve a Gitea API token for the given host.
|
# Resolve a Gitea API token for the given host.
|
||||||
# Priority: Mosaic credential loader → GITEA_TOKEN env → ~/.git-credentials
|
# Priority: Mosaic credential loader → GITEA_TOKEN env → ~/.git-credentials
|
||||||
get_gitea_token() {
|
get_gitea_token() {
|
||||||
@@ -403,7 +523,7 @@ get_gitea_token() {
|
|||||||
for svc in gitea-mosaicstack gitea-usc; do
|
for svc in gitea-mosaicstack gitea-usc; do
|
||||||
unset GITEA_TOKEN GITEA_URL
|
unset GITEA_TOKEN GITEA_URL
|
||||||
load_credentials "$svc" 2>/dev/null || continue
|
load_credentials "$svc" 2>/dev/null || continue
|
||||||
if [[ "${GITEA_URL:-}" == "https://$host" || "${GITEA_URL:-}" == "http://$host" || "${GITEA_URL:-}" == *"//$host" ]]; then
|
if gitea_url_matches_host "${GITEA_URL:-}" "$host"; then
|
||||||
matched=true
|
matched=true
|
||||||
break
|
break
|
||||||
fi
|
fi
|
||||||
@@ -423,7 +543,7 @@ get_gitea_token() {
|
|||||||
|
|
||||||
# 2. GITEA_TOKEN env var (only when GITEA_URL, if present, matches the remote host)
|
# 2. GITEA_TOKEN env var (only when GITEA_URL, if present, matches the remote host)
|
||||||
if [[ -n "${GITEA_TOKEN:-}" ]]; then
|
if [[ -n "${GITEA_TOKEN:-}" ]]; then
|
||||||
if [[ -z "${GITEA_URL:-}" || "${GITEA_URL:-}" == "https://$host" || "${GITEA_URL:-}" == "http://$host" || "${GITEA_URL:-}" == *"//$host" ]]; then
|
if [[ -z "${GITEA_URL:-}" ]] || gitea_url_matches_host "$GITEA_URL" "$host"; then
|
||||||
echo "$GITEA_TOKEN"
|
echo "$GITEA_TOKEN"
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
@@ -443,6 +563,147 @@ get_gitea_token() {
|
|||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Resolve the API token for a SPECIFIC tea login name from tea's own config
|
||||||
|
# (the same store tea itself writes/reads for `--login <name>`). This is what
|
||||||
|
# lets a REST write be performed AS the selected --login identity: tea keys its
|
||||||
|
# per-login tokens by `name` in $XDG_CONFIG_HOME/tea/config.yml (default
|
||||||
|
# ~/.config/tea/config.yml), exactly as the `tea` CLI resolves them, so a
|
||||||
|
# --login override and its REST read-back bind to the SAME credential/identity.
|
||||||
|
#
|
||||||
|
# $2 (repo host) binds the selected credential to the TARGET host: a tea login
|
||||||
|
# also records the `url` it authenticates against, and the matched login's URL
|
||||||
|
# host MUST equal the repo host. This fails closed when an override login is
|
||||||
|
# configured for a DIFFERENT host than the repo remote, so a login name shared
|
||||||
|
# across hosts (or a mistargeted override) can never send one host's credential
|
||||||
|
# to another host (cross-host credential leak). When $2 is empty the host bind
|
||||||
|
# is skipped (host-agnostic lookup) — callers that write should always pass it.
|
||||||
|
#
|
||||||
|
# Prints the token on success; returns non-zero (no output) if the config, a
|
||||||
|
# matching login token, or the host bind cannot be satisfied. Callers must not
|
||||||
|
# log the result.
|
||||||
|
get_gitea_token_for_login() {
|
||||||
|
local login_name="$1" repo_host="${2:-}" config_file
|
||||||
|
[[ -n "$login_name" ]] || return 1
|
||||||
|
config_file="${XDG_CONFIG_HOME:-$HOME/.config}/tea/config.yml"
|
||||||
|
[[ -f "$config_file" ]] || return 1
|
||||||
|
|
||||||
|
LOGIN_NAME="$login_name" REPO_HOST="$repo_host" python3 - "$config_file" <<'PY'
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
wanted = os.environ["LOGIN_NAME"]
|
||||||
|
repo_host = os.environ.get("REPO_HOST", "").strip().lower()
|
||||||
|
config_path = sys.argv[1]
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_scalar(value):
|
||||||
|
value = value.strip()
|
||||||
|
if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'):
|
||||||
|
value = value[1:-1]
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _host_of(url):
|
||||||
|
if not isinstance(url, str) or not url:
|
||||||
|
return None
|
||||||
|
parsed = urlparse(url if "//" in url else f"//{url}")
|
||||||
|
host = parsed.hostname
|
||||||
|
return host.lower() if host else None
|
||||||
|
|
||||||
|
|
||||||
|
def _accept(token, url):
|
||||||
|
# Enforce the host bind before surfacing a token. When a repo host is given,
|
||||||
|
# the login's recorded URL host must match it exactly; a login with no
|
||||||
|
# usable URL (or a mismatched one) is rejected (fail closed) so a cross-host
|
||||||
|
# credential is never emitted.
|
||||||
|
if not isinstance(token, str) or not token:
|
||||||
|
return None
|
||||||
|
if repo_host:
|
||||||
|
if _host_of(url) != repo_host:
|
||||||
|
return None
|
||||||
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
def _token_via_pyyaml():
|
||||||
|
# Preferred, fully general path when PyYAML is installed. Raises ImportError
|
||||||
|
# (caught by the caller) when the module is unavailable so the environment
|
||||||
|
# -robust fallback can take over instead of failing closed on every host
|
||||||
|
# that lacks PyYAML.
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
with open(config_path, encoding="utf-8") as handle:
|
||||||
|
config = yaml.safe_load(handle)
|
||||||
|
logins = config.get("logins") if isinstance(config, dict) else None
|
||||||
|
if not isinstance(logins, list):
|
||||||
|
return None
|
||||||
|
for login in logins:
|
||||||
|
if isinstance(login, dict) and str(login.get("name") or "") == wanted:
|
||||||
|
return _accept(login.get("token"), login.get("url"))
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _token_via_lines():
|
||||||
|
# Conservative fallback for hosts without PyYAML. tea writes config.yml in a
|
||||||
|
# fixed, flat shape (a `logins:` list of maps with scalar name/url/token
|
||||||
|
# fields), so a small indentation-aware scan resolves the SAME token PyYAML
|
||||||
|
# would. It only ever returns the `token` of the entry whose `name` EXACTLY
|
||||||
|
# equals the requested login AND whose url host matches the repo host, so it
|
||||||
|
# cannot misattribute to another identity or host; anything it cannot parse
|
||||||
|
# yields None (fail closed).
|
||||||
|
with open(config_path, encoding="utf-8") as handle:
|
||||||
|
lines = handle.read().splitlines()
|
||||||
|
|
||||||
|
logins_indent = None
|
||||||
|
start = len(lines)
|
||||||
|
for index, line in enumerate(lines):
|
||||||
|
match = re.match(r"^(\s*)logins\s*:\s*$", line)
|
||||||
|
if match:
|
||||||
|
logins_indent = len(match.group(1))
|
||||||
|
start = index + 1
|
||||||
|
break
|
||||||
|
if logins_indent is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
entries = []
|
||||||
|
current = None
|
||||||
|
for line in lines[start:]:
|
||||||
|
if not line.strip() or line.lstrip().startswith("#"):
|
||||||
|
continue
|
||||||
|
indent = len(line) - len(line.lstrip(" "))
|
||||||
|
if indent <= logins_indent:
|
||||||
|
break
|
||||||
|
item = re.match(r"^\s*-\s*(.*)$", line)
|
||||||
|
rest = item.group(1) if item else line
|
||||||
|
if item:
|
||||||
|
current = {}
|
||||||
|
entries.append(current)
|
||||||
|
pair = re.match(r"^([A-Za-z0-9_]+)\s*:\s*(.*)$", rest.strip())
|
||||||
|
if pair and current is not None:
|
||||||
|
current[pair.group(1)] = _strip_scalar(pair.group(2))
|
||||||
|
|
||||||
|
for entry in entries:
|
||||||
|
if str(entry.get("name") or "") == wanted:
|
||||||
|
return _accept(entry.get("token"), entry.get("url"))
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
token = _token_via_pyyaml()
|
||||||
|
except ImportError:
|
||||||
|
token = _token_via_lines()
|
||||||
|
except Exception:
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
if isinstance(token, str) and token:
|
||||||
|
print(token)
|
||||||
|
raise SystemExit(0)
|
||||||
|
raise SystemExit(1)
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
# Resolve HTTPS basic auth credentials for a Gitea host from ~/.git-credentials.
|
# Resolve HTTPS basic auth credentials for a Gitea host from ~/.git-credentials.
|
||||||
# Prints "username:password" for direct curl -u consumption. Callers must not log it.
|
# Prints "username:password" for direct curl -u consumption. Callers must not log it.
|
||||||
get_gitea_basic_auth() {
|
get_gitea_basic_auth() {
|
||||||
|
|||||||
@@ -1,6 +1,26 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# issue-comment.sh - Add a comment to an issue on GitHub or Gitea
|
# issue-comment.sh - Add a comment to an issue on GitHub or Gitea
|
||||||
# Usage: issue-comment.sh -i <issue_number> -c <comment>
|
# 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
|
set -e
|
||||||
|
|
||||||
@@ -10,6 +30,7 @@ source "$SCRIPT_DIR/detect-platform.sh"
|
|||||||
# Parse arguments
|
# Parse arguments
|
||||||
ISSUE_NUMBER=""
|
ISSUE_NUMBER=""
|
||||||
COMMENT=""
|
COMMENT=""
|
||||||
|
LOGIN_OVERRIDE=""
|
||||||
|
|
||||||
while [[ $# -gt 0 ]]; do
|
while [[ $# -gt 0 ]]; do
|
||||||
case $1 in
|
case $1 in
|
||||||
@@ -21,12 +42,17 @@ while [[ $# -gt 0 ]]; do
|
|||||||
COMMENT="$2"
|
COMMENT="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
|
-l|--login)
|
||||||
|
LOGIN_OVERRIDE="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
-h|--help)
|
-h|--help)
|
||||||
echo "Usage: issue-comment.sh -i <issue_number> -c <comment>"
|
echo "Usage: issue-comment.sh -i <issue_number> -c <comment> [--login <name>]"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Options:"
|
echo "Options:"
|
||||||
echo " -i, --issue Issue number (required)"
|
echo " -i, --issue Issue number (required)"
|
||||||
echo " -c, --comment Comment text (required)"
|
echo " -c, --comment Comment text (required)"
|
||||||
|
echo " -l, --login Override the detected Gitea tea login for this call"
|
||||||
echo " -h, --help Show this help"
|
echo " -h, --help Show this help"
|
||||||
exit 0
|
exit 0
|
||||||
;;
|
;;
|
||||||
@@ -49,20 +75,233 @@ fi
|
|||||||
|
|
||||||
detect_platform >/dev/null
|
detect_platform >/dev/null
|
||||||
|
|
||||||
|
# Resolve and cache the Gitea REST endpoint + token for the current remote,
|
||||||
|
# bound to a SPECIFIC login identity ($1). Populates GITEA_API_ROOT (…/api/v1),
|
||||||
|
# GITEA_API_BASE (…/api/v1/repos/<slug>), and GITEA_API_TOKEN.
|
||||||
|
#
|
||||||
|
# The token is resolved for the EFFECTIVE login (the --login override when
|
||||||
|
# given, otherwise the detected default) so that the single credential used for
|
||||||
|
# the write ALSO drives the /user identity read and the read-back — write token
|
||||||
|
# and read-back token are the same identity by construction (this is the
|
||||||
|
# credential-ordering fix: a --login override is no longer written under one
|
||||||
|
# credential and verified under a different default one). Falls back to the
|
||||||
|
# host-scoped credential ONLY when NO --login override was supplied (the
|
||||||
|
# best-effort default path). When $2 is "explicit" the login came from a
|
||||||
|
# caller-supplied --login: that exact login's token MUST resolve, and we FAIL
|
||||||
|
# CLOSED rather than silently downgrading the write to the host default
|
||||||
|
# identity — otherwise a caller relying on a dedicated per-role credential would
|
||||||
|
# be told the write succeeded as requested while it was attributed to the shared
|
||||||
|
# default. Returns non-zero (clear stderr) on any resolution failure.
|
||||||
|
gitea_resolve_api_for_login() {
|
||||||
|
local effective_login="$1" override_explicit="${2:-}" host configured_url repo
|
||||||
|
|
||||||
|
host=$(get_remote_host)
|
||||||
|
if [[ -n "$override_explicit" ]]; then
|
||||||
|
GITEA_API_TOKEN=$(get_gitea_token_for_login "$effective_login" "$host") || {
|
||||||
|
echo "Error: could not resolve a host-matched Gitea token for --login '$effective_login' on host '$host'; refusing to fall back to the host default identity or a cross-host credential (comment write/read-back)" >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
else
|
||||||
|
GITEA_API_TOKEN=$(get_gitea_token_for_login "$effective_login" "$host") \
|
||||||
|
|| GITEA_API_TOKEN=$(get_gitea_token "$host") || {
|
||||||
|
echo "Error: Gitea token not found for login '$effective_login' (comment write/read-back)" >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
fi
|
||||||
|
configured_url=$(get_gitea_url_for_host "$host") || {
|
||||||
|
echo "Error: Configured Gitea URL not found for comment read-back verification" >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
repo=$(get_gitea_repo_slug_for_url "$configured_url") || {
|
||||||
|
echo "Error: Could not resolve Gitea owner/repository relative to configured URL" >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
GITEA_API_ROOT="${configured_url%/}/api/v1"
|
||||||
|
GITEA_API_BASE="$GITEA_API_ROOT/repos/$repo"
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# Resolve the login of the identity the API token authenticates as (GET
|
||||||
|
# /user). Used to attribute a read-back record to THIS invocation's writer so
|
||||||
|
# a concurrent write from a DIFFERENT identity cannot satisfy verification.
|
||||||
|
# Prints the login on success.
|
||||||
|
gitea_authenticated_login() {
|
||||||
|
local response_file status
|
||||||
|
|
||||||
|
response_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-issue-comment-whoami.XXXXXX")
|
||||||
|
trap 'rm -f "$response_file"' RETURN
|
||||||
|
|
||||||
|
if ! status=$(curl -sS -o "$response_file" -w '%{http_code}' \
|
||||||
|
-H "Authorization: token $GITEA_API_TOKEN" \
|
||||||
|
"$GITEA_API_ROOT/user"); then
|
||||||
|
echo "Error: Gitea authenticated-identity read transport failed" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if [[ "$status" != "200" ]]; then
|
||||||
|
echo "Error: Gitea authenticated-identity read failed with HTTP $status" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
python3 - "$response_file" <<'PY'
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(sys.argv[1], encoding="utf-8") as response:
|
||||||
|
user = json.load(response)
|
||||||
|
login = user.get("login") if isinstance(user, dict) else None
|
||||||
|
if not isinstance(login, str) or not login:
|
||||||
|
raise ValueError("missing authenticated login")
|
||||||
|
except (OSError, json.JSONDecodeError, TypeError, ValueError) as error:
|
||||||
|
print(f"Error: could not resolve authenticated Gitea identity: {error}", file=sys.stderr)
|
||||||
|
raise SystemExit(1)
|
||||||
|
print(login)
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
|
# Post a comment to a Gitea issue via the supported REST API and verify it
|
||||||
|
# durably against a PROVIDER-RETURNED created id — never trust an exit code
|
||||||
|
# (#865 defect class: tea's non-existent `tea issue comment` no-ops yet exits
|
||||||
|
# 0). The write is a direct POST that returns the created comment object, so we
|
||||||
|
# learn the exact id of THIS write; we then GET that exact id and require
|
||||||
|
# id == created id AND author == acting identity AND exact body AND that it
|
||||||
|
# belongs to this issue. Because verification is keyed to the id the create
|
||||||
|
# returned, a concurrent comment (even same identity, same body) CANNOT
|
||||||
|
# masquerade as this write, and a suppressed/no-op write yields no created id
|
||||||
|
# and fails closed — there is no fallback list scan that a concurrent record
|
||||||
|
# could satisfy. Prints the created comment id on success.
|
||||||
|
#
|
||||||
|
# Args: $1 = issue number, $2 = comment body, $3 = acting identity login.
|
||||||
|
gitea_create_comment_verified() {
|
||||||
|
local issue_number="$1" comment_body="$2" acting_login="$3"
|
||||||
|
local payload write_file readback_file write_status readback_status created_id
|
||||||
|
|
||||||
|
payload=$(COMMENT_BODY="$comment_body" python3 -c '
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
print(json.dumps({"body": os.environ["COMMENT_BODY"]}))
|
||||||
|
')
|
||||||
|
write_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-issue-comment-write.XXXXXX")
|
||||||
|
readback_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-issue-comment-getid.XXXXXX")
|
||||||
|
trap 'rm -f "$write_file" "$readback_file"' RETURN
|
||||||
|
|
||||||
|
if ! write_status=$(curl -sS -o "$write_file" -w '%{http_code}' \
|
||||||
|
-X POST \
|
||||||
|
-H "Authorization: token $GITEA_API_TOKEN" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d "$payload" \
|
||||||
|
"$GITEA_API_BASE/issues/$issue_number/comments"); then
|
||||||
|
echo "Error: Gitea comment write transport failed" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if [[ "$write_status" != "201" ]]; then
|
||||||
|
echo "Error: Gitea comment write failed with HTTP $write_status (#865: no durable comment created)" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
created_id=$(python3 - "$write_file" <<'PY'
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(sys.argv[1], encoding="utf-8") as response:
|
||||||
|
comment = json.load(response)
|
||||||
|
created_id = comment.get("id") if isinstance(comment, dict) else None
|
||||||
|
if not isinstance(created_id, int) or created_id <= 0:
|
||||||
|
raise ValueError("create response carried no positive comment id")
|
||||||
|
except (OSError, json.JSONDecodeError, ValueError) as error:
|
||||||
|
print(f"Error: could not identify created Gitea comment: {error}", file=sys.stderr)
|
||||||
|
raise SystemExit(1)
|
||||||
|
print(created_id)
|
||||||
|
PY
|
||||||
|
) || return 1
|
||||||
|
|
||||||
|
if ! readback_status=$(curl -sS -o "$readback_file" -w '%{http_code}' \
|
||||||
|
-H "Authorization: token $GITEA_API_TOKEN" \
|
||||||
|
"$GITEA_API_BASE/issues/comments/$created_id"); then
|
||||||
|
echo "Error: Gitea comment read-back transport failed" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if [[ "$readback_status" != "200" ]]; then
|
||||||
|
echo "Error: Gitea comment read-back failed with HTTP $readback_status" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
EXPECTED_COMMENT_ID="$created_id" EXPECTED_COMMENT_BODY="$comment_body" \
|
||||||
|
ACTING_LOGIN="$acting_login" EXPECTED_REPO_SLUG="${GITEA_API_BASE##*/repos/}" \
|
||||||
|
EXPECTED_NUMBER="$issue_number" \
|
||||||
|
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"]
|
||||||
|
slug = os.environ["EXPECTED_REPO_SLUG"]
|
||||||
|
number = os.environ["EXPECTED_NUMBER"]
|
||||||
|
# Gitea populates WEB (html) URLs here, not API paths. A plain issue comment
|
||||||
|
# carries issue_url = <app>/<owner>/<repo>/issues/<n> (pull_request_url
|
||||||
|
# empty); a comment posted to a PR's conversation carries
|
||||||
|
# pull_request_url = <app>/<owner>/<repo>/pulls/<n> (issue_url empty).
|
||||||
|
# Accept whichever the provider populated — scoped to THIS repo slug and
|
||||||
|
# number — so a genuine write is never rejected merely for URL shape.
|
||||||
|
issue_suffix = f"/{slug}/issues/{number}"
|
||||||
|
pr_suffix = f"/{slug}/pulls/{number}"
|
||||||
|
issue_path = urlparse(comment.get("issue_url") or "").path.rstrip("/")
|
||||||
|
pr_path = urlparse(comment.get("pull_request_url") or "").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(issue_suffix) or pr_path.endswith(pr_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
|
||||||
|
|
||||||
|
echo "$created_id"
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
if [[ "$PLATFORM" == "github" ]]; then
|
if [[ "$PLATFORM" == "github" ]]; then
|
||||||
gh issue comment "$ISSUE_NUMBER" --body "$COMMENT"
|
gh issue comment "$ISSUE_NUMBER" --body "$COMMENT"
|
||||||
echo "Added comment to GitHub issue #$ISSUE_NUMBER"
|
echo "Added comment to GitHub issue #$ISSUE_NUMBER"
|
||||||
elif [[ "$PLATFORM" == "gitea" ]]; then
|
elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||||
# Build the invocation as an argv array (not unquoted $(get_gitea_repo_args)
|
# Resolve the login this comment should be attributed to: the --login
|
||||||
# word-splitting) so the comment body — including Markdown backticks, $(...),
|
# override when given, otherwise the detected default for this repo's host.
|
||||||
# and quotes — is passed verbatim and never re-split or shell-evaluated.
|
# A --login override always wins. Otherwise name this repo host's login only
|
||||||
REPO_SLUG=$(get_repo_slug)
|
# as a best effort: the login name merely selects a per-login token, and
|
||||||
GITEA_LOGIN_NAME=$(get_gitea_login) || {
|
# gitea_resolve_api_for_login falls back to the host credential
|
||||||
echo "Error: could not resolve a Gitea login for this repo; cannot comment on issue #$ISSUE_NUMBER." >&2
|
# (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. Passing "explicit" when --login
|
||||||
|
# was supplied forbids the host-default fallback: an unresolvable explicit
|
||||||
|
# override fails closed instead of writing under the default identity.
|
||||||
|
gitea_resolve_api_for_login "$EFFECTIVE_LOGIN" "${LOGIN_OVERRIDE:+explicit}" || exit 1
|
||||||
|
ACTING_LOGIN=$(gitea_authenticated_login) || exit 1
|
||||||
|
|
||||||
|
comment_id=$(gitea_create_comment_verified "$ISSUE_NUMBER" "$COMMENT" "$ACTING_LOGIN") || {
|
||||||
|
echo "Error: could not create and verify a comment on Gitea issue #$ISSUE_NUMBER via a provider-returned created id (#865)." >&2
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
tea issue comment "$ISSUE_NUMBER" "$COMMENT" --repo "$REPO_SLUG" --login "$GITEA_LOGIN_NAME"
|
echo "Added and verified comment on Gitea issue #$ISSUE_NUMBER (comment ID $comment_id)"
|
||||||
echo "Added comment to Gitea issue #$ISSUE_NUMBER"
|
|
||||||
else
|
else
|
||||||
echo "Error: Unknown platform"
|
echo "Error: Unknown platform"
|
||||||
exit 1
|
exit 1
|
||||||
|
|||||||
@@ -1,16 +1,33 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# pr-review.sh - Review a pull request on GitHub or Gitea
|
# pr-review.sh - Review a pull request on GitHub or Gitea
|
||||||
# Usage: pr-review.sh -n <pr_number> -a <action> [-c <comment>]
|
# 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
|
set -e
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
# shellcheck source=packages/mosaic/framework/tools/git/detect-platform.sh
|
||||||
source "$SCRIPT_DIR/detect-platform.sh"
|
source "$SCRIPT_DIR/detect-platform.sh"
|
||||||
|
|
||||||
# Parse arguments
|
# Parse arguments
|
||||||
PR_NUMBER=""
|
PR_NUMBER=""
|
||||||
ACTION=""
|
ACTION=""
|
||||||
COMMENT=""
|
COMMENT=""
|
||||||
|
LOGIN_OVERRIDE=""
|
||||||
|
|
||||||
while [[ $# -gt 0 ]]; do
|
while [[ $# -gt 0 ]]; do
|
||||||
case $1 in
|
case $1 in
|
||||||
@@ -26,13 +43,18 @@ while [[ $# -gt 0 ]]; do
|
|||||||
COMMENT="$2"
|
COMMENT="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
|
-l|--login)
|
||||||
|
LOGIN_OVERRIDE="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
-h|--help)
|
-h|--help)
|
||||||
echo "Usage: pr-review.sh -n <pr_number> -a <action> [-c <comment>]"
|
echo "Usage: pr-review.sh -n <pr_number> -a <action> [-c <comment>] [--login <name>]"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Options:"
|
echo "Options:"
|
||||||
echo " -n, --number PR number (required)"
|
echo " -n, --number PR number (required)"
|
||||||
echo " -a, --action Review action: approve, request-changes, comment (required)"
|
echo " -a, --action Review action: approve, request-changes, comment (required)"
|
||||||
echo " -c, --comment Review comment (required for request-changes)"
|
echo " -c, --comment Review comment (required for request-changes)"
|
||||||
|
echo " -l, --login Override the detected Gitea tea login (approve/request-changes only)"
|
||||||
echo " -h, --help Show this help"
|
echo " -h, --help Show this help"
|
||||||
exit 0
|
exit 0
|
||||||
;;
|
;;
|
||||||
@@ -55,6 +77,347 @@ fi
|
|||||||
|
|
||||||
detect_platform >/dev/null
|
detect_platform >/dev/null
|
||||||
|
|
||||||
|
# Post a comment to a Gitea PR (PR comments ARE issue comments) via the
|
||||||
|
# supported REST API and verify it against a PROVIDER-RETURNED created id. The
|
||||||
|
# write is a direct POST that returns the created comment object, so we learn
|
||||||
|
# the exact id of THIS write; we GET that exact id and require id == created id
|
||||||
|
# AND author == acting identity AND exact body AND that it belongs to this PR.
|
||||||
|
# Keying to the returned id means no concurrent comment (even same identity /
|
||||||
|
# body) can masquerade as this write, and a no-op create yields no id and fails
|
||||||
|
# closed. Requires GITEA_API_BASE / GITEA_API_TOKEN to be resolved first (via
|
||||||
|
# gitea_resolve_api_for_login). Prints the created comment id on success.
|
||||||
|
#
|
||||||
|
# Args: $1 = PR number, $2 = comment body, $3 = acting identity login.
|
||||||
|
gitea_create_comment_verified() {
|
||||||
|
local pr_number="$1" comment_body="$2" acting_login="$3"
|
||||||
|
local payload write_file readback_file write_status readback_status created_id
|
||||||
|
|
||||||
|
payload=$(COMMENT_BODY="$comment_body" python3 -c '
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
print(json.dumps({"body": os.environ["COMMENT_BODY"]}))
|
||||||
|
')
|
||||||
|
write_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-pr-review-write.XXXXXX")
|
||||||
|
readback_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-pr-review-getid.XXXXXX")
|
||||||
|
trap 'rm -f "$write_file" "$readback_file"' RETURN
|
||||||
|
|
||||||
|
if ! write_status=$(curl -sS -o "$write_file" -w '%{http_code}' \
|
||||||
|
-X POST \
|
||||||
|
-H "Authorization: token $GITEA_API_TOKEN" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d "$payload" \
|
||||||
|
"$GITEA_API_BASE/issues/$pr_number/comments"); then
|
||||||
|
echo "Error: Gitea comment write transport failed" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if [[ "$write_status" != "201" ]]; then
|
||||||
|
echo "Error: Gitea comment write failed with HTTP $write_status" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
created_id=$(python3 - "$write_file" <<'PY'
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(sys.argv[1], encoding="utf-8") as response:
|
||||||
|
comment = json.load(response)
|
||||||
|
created_id = comment.get("id") if isinstance(comment, dict) else None
|
||||||
|
if not isinstance(created_id, int) or created_id <= 0:
|
||||||
|
raise ValueError("create response carried no positive comment id")
|
||||||
|
except (OSError, json.JSONDecodeError, ValueError) as error:
|
||||||
|
print(f"Error: could not identify created Gitea comment: {error}", file=sys.stderr)
|
||||||
|
raise SystemExit(1)
|
||||||
|
print(created_id)
|
||||||
|
PY
|
||||||
|
) || return 1
|
||||||
|
|
||||||
|
if ! readback_status=$(curl -sS -o "$readback_file" -w '%{http_code}' \
|
||||||
|
-H "Authorization: token $GITEA_API_TOKEN" \
|
||||||
|
"$GITEA_API_BASE/issues/comments/$created_id"); then
|
||||||
|
echo "Error: Gitea comment read-back transport failed" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if [[ "$readback_status" != "200" ]]; then
|
||||||
|
echo "Error: Gitea comment read-back failed with HTTP $readback_status" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
EXPECTED_COMMENT_ID="$created_id" EXPECTED_COMMENT_BODY="$comment_body" \
|
||||||
|
ACTING_LOGIN="$acting_login" EXPECTED_REPO_SLUG="${GITEA_API_BASE##*/repos/}" \
|
||||||
|
EXPECTED_NUMBER="$pr_number" \
|
||||||
|
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"]
|
||||||
|
slug = os.environ["EXPECTED_REPO_SLUG"]
|
||||||
|
number = os.environ["EXPECTED_NUMBER"]
|
||||||
|
# Gitea populates WEB (html) URLs here, not API paths. A PR-conversation
|
||||||
|
# comment carries pull_request_url = <app>/<owner>/<repo>/pulls/<n> (with
|
||||||
|
# issue_url empty), while a plain issue comment carries
|
||||||
|
# issue_url = <app>/<owner>/<repo>/issues/<n> (with pull_request_url empty).
|
||||||
|
# Accept whichever the provider populated — scoped to THIS repo slug and
|
||||||
|
# number — so a genuine write is never rejected merely for URL shape.
|
||||||
|
issue_suffix = f"/{slug}/issues/{number}"
|
||||||
|
pr_suffix = f"/{slug}/pulls/{number}"
|
||||||
|
issue_path = urlparse(comment.get("issue_url") or "").path.rstrip("/")
|
||||||
|
pr_path = urlparse(comment.get("pull_request_url") or "").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(issue_suffix) or pr_path.endswith(pr_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 NO --login override was supplied (the
|
||||||
|
# best-effort default path). When $2 is "explicit" the login came from a
|
||||||
|
# caller-supplied --login: that exact login's token MUST resolve, and we FAIL
|
||||||
|
# CLOSED rather than silently downgrading the review/comment to the host default
|
||||||
|
# identity. Returns non-zero (clear stderr) on any resolution failure.
|
||||||
|
gitea_resolve_api_for_login() {
|
||||||
|
local effective_login="$1" override_explicit="${2:-}" host configured_url repo
|
||||||
|
|
||||||
|
host=$(get_remote_host)
|
||||||
|
if [[ -n "$override_explicit" ]]; then
|
||||||
|
GITEA_API_TOKEN=$(get_gitea_token_for_login "$effective_login" "$host") || {
|
||||||
|
echo "Error: could not resolve a host-matched Gitea token for --login '$effective_login' on host '$host'; refusing to fall back to the host default identity or a cross-host credential (review write/read-back)" >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
else
|
||||||
|
GITEA_API_TOKEN=$(get_gitea_token_for_login "$effective_login" "$host") \
|
||||||
|
|| GITEA_API_TOKEN=$(get_gitea_token "$host") || {
|
||||||
|
echo "Error: Gitea token not found for login '$effective_login' (review write/read-back)" >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
fi
|
||||||
|
configured_url=$(get_gitea_url_for_host "$host") || {
|
||||||
|
echo "Error: Configured Gitea URL not found for review read-back verification" >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
repo=$(get_gitea_repo_slug_for_url "$configured_url") || {
|
||||||
|
echo "Error: Could not resolve Gitea owner/repository relative to configured URL" >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
GITEA_API_ROOT="${configured_url%/}/api/v1"
|
||||||
|
GITEA_API_BASE="$GITEA_API_ROOT/repos/$repo"
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# Resolve the login of the identity the API token authenticates as (GET
|
||||||
|
# /user). Used to attribute a read-back review to THIS action's reviewer so a
|
||||||
|
# concurrent review from a DIFFERENT identity cannot satisfy verification.
|
||||||
|
# Prints the login on success.
|
||||||
|
gitea_authenticated_login() {
|
||||||
|
local response_file status
|
||||||
|
|
||||||
|
response_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-pr-review-whoami.XXXXXX")
|
||||||
|
trap 'rm -f "$response_file"' RETURN
|
||||||
|
|
||||||
|
if ! status=$(curl -sS -o "$response_file" -w '%{http_code}' \
|
||||||
|
-H "Authorization: token $GITEA_API_TOKEN" \
|
||||||
|
"$GITEA_API_ROOT/user"); then
|
||||||
|
echo "Error: Gitea authenticated-identity read transport failed" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if [[ "$status" != "200" ]]; then
|
||||||
|
echo "Error: Gitea authenticated-identity read failed with HTTP $status" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
python3 - "$response_file" <<'PY'
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(sys.argv[1], encoding="utf-8") as response:
|
||||||
|
user = json.load(response)
|
||||||
|
login = user.get("login") if isinstance(user, dict) else None
|
||||||
|
if not isinstance(login, str) or not login:
|
||||||
|
raise ValueError("missing authenticated login")
|
||||||
|
except (OSError, json.JSONDecodeError, TypeError, ValueError) as error:
|
||||||
|
print(f"Error: could not resolve authenticated Gitea identity: {error}", file=sys.stderr)
|
||||||
|
raise SystemExit(1)
|
||||||
|
print(login)
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
|
# Resolve the PR's current head commit SHA (GET /pulls/{n}). The review is
|
||||||
|
# submitted against — and later verified as pinned to — this exact commit, so a
|
||||||
|
# stale review left over from an earlier push cannot be mistaken for this one.
|
||||||
|
# Prints the head SHA on success.
|
||||||
|
gitea_pr_head_sha() {
|
||||||
|
local pr_number="$1" pr_file status
|
||||||
|
|
||||||
|
pr_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-pr-review-head.XXXXXX")
|
||||||
|
trap 'rm -f "$pr_file"' RETURN
|
||||||
|
|
||||||
|
if ! status=$(curl -sS -o "$pr_file" -w '%{http_code}' \
|
||||||
|
-H "Authorization: token $GITEA_API_TOKEN" \
|
||||||
|
"$GITEA_API_BASE/pulls/$pr_number"); then
|
||||||
|
echo "Error: Gitea PR head read transport failed" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if [[ "$status" != "200" ]]; then
|
||||||
|
echo "Error: Gitea PR head read failed with HTTP $status" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
python3 - "$pr_file" <<'PY'
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(sys.argv[1], encoding="utf-8") as response:
|
||||||
|
pr = json.load(response)
|
||||||
|
head_sha = pr.get("head", {}).get("sha") if isinstance(pr, dict) else None
|
||||||
|
if not isinstance(head_sha, str) or not head_sha:
|
||||||
|
raise ValueError("missing PR head sha")
|
||||||
|
except (OSError, json.JSONDecodeError, AttributeError, TypeError, ValueError) as error:
|
||||||
|
print(f"Error: could not resolve PR head commit: {error}", file=sys.stderr)
|
||||||
|
raise SystemExit(1)
|
||||||
|
print(head_sha)
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
|
# Submit a review to a Gitea PR via the supported REST API and verify it against
|
||||||
|
# a PROVIDER-RETURNED created id. tea 0.11.1's `pr approve`/`reject` cannot emit
|
||||||
|
# the id of the review it created and can silently no-op while exiting 0 (#865
|
||||||
|
# defect class), so this does NOT shell out to tea: it POSTs to
|
||||||
|
# /pulls/{n}/reviews with the event (APPROVED / REQUEST_CHANGES), the PR head
|
||||||
|
# commit_id, and the review body, which returns the created review object
|
||||||
|
# including its id. It then GETs that exact review id and requires
|
||||||
|
# id == created id AND author == acting identity AND state == expected AND
|
||||||
|
# commit_id == PR head. Keying to the returned id means no concurrent review
|
||||||
|
# (even same identity/state/head) can masquerade as this one, and a no-op
|
||||||
|
# submit yields no id and fails closed. Prints the created review id on success.
|
||||||
|
#
|
||||||
|
# Args: $1 = PR number, $2 = event (APPROVED|REQUEST_CHANGES),
|
||||||
|
# $3 = review body (may be empty for APPROVED), $4 = acting login,
|
||||||
|
# $5 = PR head sha.
|
||||||
|
gitea_submit_review_verified() {
|
||||||
|
local pr_number="$1" event="$2" review_body="$3" acting_login="$4" head_sha="$5"
|
||||||
|
local payload write_file readback_file write_status readback_status created_id
|
||||||
|
|
||||||
|
payload=$(REVIEW_EVENT="$event" REVIEW_BODY="$review_body" REVIEW_COMMIT="$head_sha" python3 -c '
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
print(json.dumps({
|
||||||
|
"event": os.environ["REVIEW_EVENT"],
|
||||||
|
"body": os.environ["REVIEW_BODY"],
|
||||||
|
"commit_id": os.environ["REVIEW_COMMIT"],
|
||||||
|
}))
|
||||||
|
')
|
||||||
|
write_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-pr-review-submit.XXXXXX")
|
||||||
|
readback_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-pr-review-getid.XXXXXX")
|
||||||
|
trap 'rm -f "$write_file" "$readback_file"' RETURN
|
||||||
|
|
||||||
|
if ! write_status=$(curl -sS -o "$write_file" -w '%{http_code}' \
|
||||||
|
-X POST \
|
||||||
|
-H "Authorization: token $GITEA_API_TOKEN" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d "$payload" \
|
||||||
|
"$GITEA_API_BASE/pulls/$pr_number/reviews"); then
|
||||||
|
echo "Error: Gitea review submit transport failed" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
# Gitea returns 200 (occasionally 201) with the created review object.
|
||||||
|
if [[ "$write_status" != "200" && "$write_status" != "201" ]]; then
|
||||||
|
echo "Error: Gitea review submit failed with HTTP $write_status (#865: no durable review created)" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
created_id=$(python3 - "$write_file" <<'PY'
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(sys.argv[1], encoding="utf-8") as response:
|
||||||
|
review = json.load(response)
|
||||||
|
created_id = review.get("id") if isinstance(review, dict) else None
|
||||||
|
if not isinstance(created_id, int) or created_id <= 0:
|
||||||
|
raise ValueError("submit response carried no positive review id")
|
||||||
|
except (OSError, json.JSONDecodeError, ValueError) as error:
|
||||||
|
print(f"Error: could not identify created Gitea review: {error}", file=sys.stderr)
|
||||||
|
raise SystemExit(1)
|
||||||
|
print(created_id)
|
||||||
|
PY
|
||||||
|
) || return 1
|
||||||
|
|
||||||
|
if ! readback_status=$(curl -sS -o "$readback_file" -w '%{http_code}' \
|
||||||
|
-H "Authorization: token $GITEA_API_TOKEN" \
|
||||||
|
"$GITEA_API_BASE/pulls/$pr_number/reviews/$created_id"); then
|
||||||
|
echo "Error: Gitea review read-back transport failed" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if [[ "$readback_status" != "200" ]]; then
|
||||||
|
echo "Error: Gitea review read-back failed with HTTP $readback_status" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
EXPECTED_REVIEW_ID="$created_id" EXPECTED_STATE="$event" ACTING_LOGIN="$acting_login" \
|
||||||
|
EXPECTED_HEAD_SHA="$head_sha" \
|
||||||
|
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
|
||||||
|
|
||||||
|
echo "$created_id"
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
if [[ "$PLATFORM" == "github" ]]; then
|
if [[ "$PLATFORM" == "github" ]]; then
|
||||||
case $ACTION in
|
case $ACTION in
|
||||||
approve)
|
approve)
|
||||||
@@ -85,24 +448,77 @@ if [[ "$PLATFORM" == "github" ]]; then
|
|||||||
elif [[ "$PLATFORM" == "gitea" ]]; then
|
elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||||
case $ACTION in
|
case $ACTION in
|
||||||
approve)
|
approve)
|
||||||
tea pr approve "$PR_NUMBER" $(get_gitea_repo_args) ${COMMENT:+--comment "$COMMENT"}
|
host=$(get_remote_host)
|
||||||
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" "${LOGIN_OVERRIDE:+explicit}" || exit 1
|
||||||
|
ACTING_LOGIN=$(gitea_authenticated_login) || exit 1
|
||||||
|
head_sha=$(gitea_pr_head_sha "$PR_NUMBER") || exit 1
|
||||||
|
# The review body (if any) travels with the review itself in the REST
|
||||||
|
# submit — the created review record carries it — so there is no
|
||||||
|
# separate detached comment to reconcile.
|
||||||
|
review_id=$(gitea_submit_review_verified "$PR_NUMBER" "APPROVED" "$COMMENT" "$ACTING_LOGIN" "$head_sha") || {
|
||||||
|
echo "Error: could not submit and verify an APPROVED review on Gitea PR #$PR_NUMBER via a provider-returned created id (#865)." >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
echo "Approved and verified Gitea PR #$PR_NUMBER (review ID $review_id)"
|
||||||
;;
|
;;
|
||||||
request-changes)
|
request-changes)
|
||||||
if [[ -z "$COMMENT" ]]; then
|
if [[ -z "$COMMENT" ]]; then
|
||||||
echo "Error: Comment required for request-changes"
|
echo "Error: Comment required for request-changes"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
tea pr reject "$PR_NUMBER" $(get_gitea_repo_args) --comment "$COMMENT"
|
host=$(get_remote_host)
|
||||||
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" "${LOGIN_OVERRIDE:+explicit}" || exit 1
|
||||||
|
ACTING_LOGIN=$(gitea_authenticated_login) || exit 1
|
||||||
|
head_sha=$(gitea_pr_head_sha "$PR_NUMBER") || exit 1
|
||||||
|
review_id=$(gitea_submit_review_verified "$PR_NUMBER" "REQUEST_CHANGES" "$COMMENT" "$ACTING_LOGIN" "$head_sha") || {
|
||||||
|
echo "Error: could not submit and verify a REQUEST_CHANGES review on Gitea PR #$PR_NUMBER via a provider-returned created id (#865)." >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
echo "Requested changes and verified on Gitea PR #$PR_NUMBER (review ID $review_id)"
|
||||||
;;
|
;;
|
||||||
comment)
|
comment)
|
||||||
if [[ -z "$COMMENT" ]]; then
|
if [[ -z "$COMMENT" ]]; then
|
||||||
echo "Error: Comment required"
|
echo "Error: Comment required"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
tea pr comment "$PR_NUMBER" "$COMMENT" $(get_gitea_repo_args)
|
host=$(get_remote_host)
|
||||||
echo "Added comment to 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" "${LOGIN_OVERRIDE:+explicit}" || exit 1
|
||||||
|
ACTING_LOGIN=$(gitea_authenticated_login) || exit 1
|
||||||
|
comment_id=$(gitea_create_comment_verified "$PR_NUMBER" "$COMMENT" "$ACTING_LOGIN") || {
|
||||||
|
echo "Error: could not create and verify a comment on Gitea PR #$PR_NUMBER via a provider-returned created id (#865)." >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
echo "Added and verified comment on Gitea PR #$PR_NUMBER (comment ID $comment_id)"
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
echo "Error: Unknown action: $ACTION"
|
echo "Error: Unknown action: $ACTION"
|
||||||
|
|||||||
494
packages/mosaic/framework/tools/git/test-issue-comment-readback.sh
Executable file
494
packages/mosaic/framework/tools/git/test-issue-comment-readback.sh
Executable file
@@ -0,0 +1,494 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Regression harness for issue-comment.sh's Gitea comment write + verification
|
||||||
|
# (#865).
|
||||||
|
#
|
||||||
|
# The #865 defect class: tea 0.11.1's `tea issue comment ...` (a nonexistent
|
||||||
|
# subcommand) silently no-ops yet exits 0, and tea cannot emit the id of a
|
||||||
|
# record it created — so an exit code is worthless as proof of a durable write.
|
||||||
|
# The wrapper therefore does NOT write via tea at all. It POSTs the comment to
|
||||||
|
# the Gitea REST API (which returns the created comment object, including its
|
||||||
|
# id), then GETs THAT EXACT id back and requires it to match on id, author
|
||||||
|
# (acting identity), body, and issue. Because verification is keyed to the id
|
||||||
|
# the create returned, no concurrent comment can masquerade as this write, and a
|
||||||
|
# suppressed/no-op create yields no id and fails closed.
|
||||||
|
#
|
||||||
|
# This harness models a REAL server: the curl stub keeps persistent comment
|
||||||
|
# state on disk, the POST actually CREATES and PERSISTS a record and returns its
|
||||||
|
# id, and the read-back GET reads that same state. There is no independently
|
||||||
|
# fabricated record for the wrapper to "find" — the only way verification
|
||||||
|
# passes is if the POST genuinely created the record the read-back retrieves.
|
||||||
|
# It proves the wrapper:
|
||||||
|
# 1. never shells out to tea to write (no `tea comment` / `tea issue comment`);
|
||||||
|
# 2. creates the comment via REST POST and learns the provider-returned id;
|
||||||
|
# 3. verifies THAT EXACT id by direct GET, attributed to the acting identity;
|
||||||
|
# 4. fails closed when the write is a no-op even though a concurrent
|
||||||
|
# SAME-IDENTITY comment with the same body already exists (the closed
|
||||||
|
# concurrency window — no fallback list scan can rescue a no-op);
|
||||||
|
# 5. fails closed when the created record is not authored by the acting
|
||||||
|
# identity;
|
||||||
|
# 6. treats the exact-id GET as the SOLE authority — it performs NO follow-up
|
||||||
|
# list enumeration (the stub exposes no comment-list endpoint, so any
|
||||||
|
# residual enumeration attempt would fail the run);
|
||||||
|
# 7. with a RESOLVABLE --login override, performs the write, the /user identity
|
||||||
|
# lookup, and the read-back ALL under THAT login's token/identity — never
|
||||||
|
# the host default;
|
||||||
|
# 8. with an UNRESOLVABLE --login override, FAILS CLOSED (nonzero, no write, no
|
||||||
|
# success line) instead of silently downgrading to the host default
|
||||||
|
# identity — the token seam maps each bearer token to the identity it
|
||||||
|
# authenticates as, so a misattributed write is caught;
|
||||||
|
# 9. with a --login override whose tea config URL is a DIFFERENT host than the
|
||||||
|
# repo remote, FAILS CLOSED (host-bound token selection) rather than sending
|
||||||
|
# that other host's credential cross-host;
|
||||||
|
# 10. leaves NO temp files behind (POST/GET bodies + metadata) on either the
|
||||||
|
# success or the failure path — nested function-scoped RETURN traps do not
|
||||||
|
# clobber each other and every scratch file is removed on all exit paths.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/issue-comment-readback}"
|
||||||
|
REPO_DIR="$WORK_DIR/repo"
|
||||||
|
BIN_DIR="$WORK_DIR/bin"
|
||||||
|
XDG_DIR="$WORK_DIR/xdg"
|
||||||
|
TEA_LOG="$WORK_DIR/tea.log"
|
||||||
|
CURL_LOG="$WORK_DIR/curl.log"
|
||||||
|
AUTH_LOG="$WORK_DIR/auth.log"
|
||||||
|
OUTPUT_FILE="$WORK_DIR/output.log"
|
||||||
|
CREDENTIALS_FILE="$WORK_DIR/credentials.json"
|
||||||
|
STATE_FILE="$WORK_DIR/comments.json"
|
||||||
|
# A dedicated scratch dir the wrapper is pointed at via TMPDIR, so the leak
|
||||||
|
# check can assert every POST/GET body + metadata temp file is cleaned up.
|
||||||
|
TMP_SCRATCH="$WORK_DIR/scratch"
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
rm -rf "$WORK_DIR"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
mkdir -p "$REPO_DIR" "$BIN_DIR" "$XDG_DIR" "$TMP_SCRATCH"
|
||||||
|
git -C "$REPO_DIR" init -q
|
||||||
|
git -C "$REPO_DIR" remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git
|
||||||
|
|
||||||
|
ISSUE_NUMBER=7
|
||||||
|
REPO_SLUG="mosaicstack/stack"
|
||||||
|
API_BASE="https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack"
|
||||||
|
API_ROOT="https://git.mosaicstack.dev/api/v1"
|
||||||
|
BODY='durable "note" -- marker'
|
||||||
|
ACTING_LOGIN="primary-reviewer"
|
||||||
|
FOREIGN_LOGIN="other-writer"
|
||||||
|
# A dedicated per-role --login override identity, with its own token stored in
|
||||||
|
# tea's config (exactly the author-not-equal-reviewer hardening path).
|
||||||
|
OVERRIDE_LOGIN="delegated-reviewer"
|
||||||
|
DEFAULT_TOKEN="test-only-placeholder"
|
||||||
|
OVERRIDE_TOKEN="override-token-placeholder"
|
||||||
|
# A --login override whose tea config URL points at a DIFFERENT Gitea host than
|
||||||
|
# the repo remote (git.mosaicstack.dev). Its token must NEVER be sent to the
|
||||||
|
# repo host: host-bound selection must fail closed on the host mismatch.
|
||||||
|
CROSS_HOST_LOGIN="foreign-host-reviewer"
|
||||||
|
CROSS_HOST_TOKEN="cross-host-token-placeholder"
|
||||||
|
|
||||||
|
# tea config: the override login has its own token here (as tea itself stores
|
||||||
|
# per-login tokens). The default login name ("mosaicstack") is deliberately NOT
|
||||||
|
# present, so the no-override default path resolves via the host credential
|
||||||
|
# fallback while an explicit --login must resolve from this file or fail closed.
|
||||||
|
# A second login is configured for a DIFFERENT host to exercise host-bound
|
||||||
|
# rejection.
|
||||||
|
mkdir -p "$XDG_DIR/tea"
|
||||||
|
OVERRIDE_LOGIN="$OVERRIDE_LOGIN" OVERRIDE_TOKEN="$OVERRIDE_TOKEN" \
|
||||||
|
CROSS_HOST_LOGIN="$CROSS_HOST_LOGIN" CROSS_HOST_TOKEN="$CROSS_HOST_TOKEN" \
|
||||||
|
python3 - "$XDG_DIR/tea/config.yml" <<'PY'
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
with open(sys.argv[1], "w", encoding="utf-8") as handle:
|
||||||
|
handle.write("logins:\n")
|
||||||
|
handle.write(f" - name: {os.environ['OVERRIDE_LOGIN']}\n")
|
||||||
|
handle.write(" url: https://git.mosaicstack.dev\n")
|
||||||
|
handle.write(f" token: {os.environ['OVERRIDE_TOKEN']}\n")
|
||||||
|
handle.write(f" - name: {os.environ['CROSS_HOST_LOGIN']}\n")
|
||||||
|
handle.write(" url: https://git.uscllc.com\n")
|
||||||
|
handle.write(f" token: {os.environ['CROSS_HOST_TOKEN']}\n")
|
||||||
|
PY
|
||||||
|
|
||||||
|
CONFIGURED_GITEA_URL="https://git.mosaicstack.dev" python3 - "$CREDENTIALS_FILE" <<'PY'
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
with open(sys.argv[1], "w", encoding="utf-8") as credentials:
|
||||||
|
json.dump({
|
||||||
|
"gitea": {
|
||||||
|
"mosaicstack": {
|
||||||
|
"url": os.environ["CONFIGURED_GITEA_URL"],
|
||||||
|
"token": "test-only-placeholder",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, credentials)
|
||||||
|
PY
|
||||||
|
|
||||||
|
# tea stub: only ever answers the login list (used to resolve the default login
|
||||||
|
# name). It must NEVER be asked to write a comment — the wrapper writes via REST.
|
||||||
|
cat > "$BIN_DIR/tea" <<'SH'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
printf '%s\n' "$*" >> "$ISSUE_COMMENT_TEA_LOG"
|
||||||
|
|
||||||
|
if [[ "$*" == "login list --output json" ]]; then
|
||||||
|
printf '%s\n' '[{"name":"mosaicstack","url":"https://git.mosaicstack.dev"}]'
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Unexpected tea command (wrapper must not write via tea): $*" >&2
|
||||||
|
exit 92
|
||||||
|
SH
|
||||||
|
chmod +x "$BIN_DIR/tea"
|
||||||
|
|
||||||
|
# curl stub: a small REST server backed by persistent on-disk comment state.
|
||||||
|
# GET /user -> acting identity
|
||||||
|
# POST /issues/7/comments -> CREATE + PERSIST, return created object
|
||||||
|
# GET /issues/comments/{id} -> read the persisted record by exact id
|
||||||
|
# There is deliberately NO comment-LIST endpoint: exact-id read-back is the sole
|
||||||
|
# authority, so any residual list enumeration attempt hits the unexpected-request
|
||||||
|
# guard and fails the test.
|
||||||
|
cat > "$BIN_DIR/curl" <<'SH'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
output_file=""
|
||||||
|
method="GET"
|
||||||
|
url=""
|
||||||
|
data=""
|
||||||
|
auth_token=""
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
-o) output_file="$2"; shift 2 ;;
|
||||||
|
-H)
|
||||||
|
[[ "$2" == Authorization:* ]] && auth_token="${2##* }"
|
||||||
|
shift 2 ;;
|
||||||
|
-w) shift 2 ;;
|
||||||
|
-X) method="$2"; shift 2 ;;
|
||||||
|
-d|--data) data="$2"; shift 2 ;;
|
||||||
|
-s|-S|-sS) shift ;;
|
||||||
|
http://*|https://*) url="$1"; shift ;;
|
||||||
|
*) shift ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
path="${url%%\?*}"
|
||||||
|
query="${url#*\?}"
|
||||||
|
[[ "$query" == "$url" ]] && query=""
|
||||||
|
printf '%s %s\n' "$method" "$url" >> "$ISSUE_COMMENT_CURL_LOG"
|
||||||
|
|
||||||
|
# Map the presented bearer token to the identity it authenticates as — the same
|
||||||
|
# derivation Gitea's own /user does. The wrapper's write, /user lookup, and
|
||||||
|
# read-back must all carry the SAME token, so the acting identity recorded here
|
||||||
|
# reveals which credential actually performed the request.
|
||||||
|
acting_identity=""
|
||||||
|
case "$auth_token" in
|
||||||
|
"$ISSUE_COMMENT_DEFAULT_TOKEN") acting_identity="$ISSUE_COMMENT_ACTING_LOGIN" ;;
|
||||||
|
"$ISSUE_COMMENT_OVERRIDE_TOKEN") acting_identity="$ISSUE_COMMENT_OVERRIDE_LOGIN" ;;
|
||||||
|
"$ISSUE_COMMENT_CROSS_HOST_TOKEN") acting_identity="$ISSUE_COMMENT_CROSS_HOST_LOGIN" ;;
|
||||||
|
esac
|
||||||
|
printf '%s %s %s\n' "$method" "$path" "${acting_identity:-<unauthenticated>}" >> "$ISSUE_COMMENT_AUTH_LOG"
|
||||||
|
|
||||||
|
write_response() {
|
||||||
|
local status="$1" body="$2"
|
||||||
|
[[ -n "$output_file" ]] || exit 96
|
||||||
|
printf '%s' "$body" > "$output_file"
|
||||||
|
printf '%s' "$status"
|
||||||
|
}
|
||||||
|
|
||||||
|
if [[ "$method" == "GET" && "$path" == "$ISSUE_COMMENT_API_ROOT/user" ]]; then
|
||||||
|
[[ -n "$acting_identity" ]] || { write_response 401 '{"message":"unauthenticated"}'; exit 0; }
|
||||||
|
write_response 200 "$(ISSUE_COMMENT_LOGIN="$acting_identity" python3 - <<'PY'
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
print(json.dumps({"login": os.environ["ISSUE_COMMENT_LOGIN"]}))
|
||||||
|
PY
|
||||||
|
)"
|
||||||
|
elif [[ "$method" == "POST" && "$path" == "$ISSUE_COMMENT_API_BASE/issues/7/comments" ]]; then
|
||||||
|
result=$(ISSUE_COMMENT_ACTING_LOGIN="${acting_identity:-$ISSUE_COMMENT_ACTING_LOGIN}" ISSUE_COMMENT_DATA="$data" python3 - <<'PY'
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
state_path = os.environ["ISSUE_COMMENT_STATE"]
|
||||||
|
mode = os.environ["ISSUE_COMMENT_TEST_MODE"]
|
||||||
|
acting = os.environ["ISSUE_COMMENT_ACTING_LOGIN"]
|
||||||
|
foreign = os.environ["ISSUE_COMMENT_FOREIGN_LOGIN"]
|
||||||
|
repo = os.environ["ISSUE_COMMENT_REPO_SLUG"]
|
||||||
|
body = json.loads(os.environ["ISSUE_COMMENT_DATA"]).get("body")
|
||||||
|
|
||||||
|
with open(state_path, encoding="utf-8") as handle:
|
||||||
|
comments = json.load(handle)
|
||||||
|
|
||||||
|
# no-op-concurrent: the wrapper's own write is SUPPRESSED (returns 200 with no
|
||||||
|
# created object) even though a concurrent same-identity comment already exists
|
||||||
|
# in state. Nothing is persisted; there is no created id to verify.
|
||||||
|
if mode == "no-op-concurrent":
|
||||||
|
print("200")
|
||||||
|
print(json.dumps({}))
|
||||||
|
raise SystemExit(0)
|
||||||
|
|
||||||
|
author = foreign if mode == "author-mismatch" else acting
|
||||||
|
new_id = (max((c["id"] for c in comments), default=0)) + 1
|
||||||
|
record = {
|
||||||
|
"id": new_id,
|
||||||
|
"body": body,
|
||||||
|
"user": {"login": author},
|
||||||
|
# REAL Gitea comment shape: issue_url is the WEB (html) path, not an API
|
||||||
|
# path, and a plain issue comment leaves pull_request_url empty.
|
||||||
|
"issue_url": f"https://git.mosaicstack.dev/{repo}/issues/7",
|
||||||
|
"pull_request_url": "",
|
||||||
|
}
|
||||||
|
comments.append(record)
|
||||||
|
with open(state_path, "w", encoding="utf-8") as handle:
|
||||||
|
json.dump(comments, handle)
|
||||||
|
print("201")
|
||||||
|
print(json.dumps(record))
|
||||||
|
PY
|
||||||
|
)
|
||||||
|
write_response "$(printf '%s' "$result" | head -n1)" "$(printf '%s' "$result" | tail -n +2)"
|
||||||
|
elif [[ "$method" == "GET" && "$path" == "$ISSUE_COMMENT_API_BASE"/issues/comments/* ]]; then
|
||||||
|
result=$(ISSUE_COMMENT_GET_ID="${path##*/}" python3 - <<'PY'
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
state_path = os.environ["ISSUE_COMMENT_STATE"]
|
||||||
|
wanted = int(os.environ["ISSUE_COMMENT_GET_ID"])
|
||||||
|
with open(state_path, encoding="utf-8") as handle:
|
||||||
|
comments = json.load(handle)
|
||||||
|
match = next((c for c in comments if c["id"] == wanted), None)
|
||||||
|
if match is None:
|
||||||
|
print("404")
|
||||||
|
print(json.dumps({"message": "not found"}))
|
||||||
|
else:
|
||||||
|
print("200")
|
||||||
|
print(json.dumps(match))
|
||||||
|
PY
|
||||||
|
)
|
||||||
|
write_response "$(printf '%s' "$result" | head -n1)" "$(printf '%s' "$result" | tail -n +2)"
|
||||||
|
else
|
||||||
|
echo "Unexpected curl request: $method $url" >&2
|
||||||
|
exit 97
|
||||||
|
fi
|
||||||
|
SH
|
||||||
|
chmod +x "$BIN_DIR/curl"
|
||||||
|
|
||||||
|
# Seed persistent server state for a mode, then run the wrapper against it.
|
||||||
|
seed_state() {
|
||||||
|
local mode="$1"
|
||||||
|
ISSUE_COMMENT_SEED_MODE="$mode" ISSUE_COMMENT_SEED_BODY="$BODY" \
|
||||||
|
ISSUE_COMMENT_SEED_ACTING="$ACTING_LOGIN" ISSUE_COMMENT_SEED_REPO="$REPO_SLUG" \
|
||||||
|
python3 - "$STATE_FILE" <<'PY'
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
mode = os.environ["ISSUE_COMMENT_SEED_MODE"]
|
||||||
|
body = os.environ["ISSUE_COMMENT_SEED_BODY"]
|
||||||
|
acting = os.environ["ISSUE_COMMENT_SEED_ACTING"]
|
||||||
|
repo = os.environ["ISSUE_COMMENT_SEED_REPO"]
|
||||||
|
# REAL Gitea comment shape: issue_url is the WEB path, pull_request_url empty.
|
||||||
|
issue_url = f"https://git.mosaicstack.dev/{repo}/issues/7"
|
||||||
|
|
||||||
|
|
||||||
|
def comment(cid, text, author):
|
||||||
|
return {
|
||||||
|
"id": cid,
|
||||||
|
"body": text,
|
||||||
|
"user": {"login": author},
|
||||||
|
"issue_url": issue_url,
|
||||||
|
"pull_request_url": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if mode == "fresh-success":
|
||||||
|
# 50 pre-existing comments already exist; the comment this run creates
|
||||||
|
# becomes id 51, proving exact-id read-back works regardless of how many
|
||||||
|
# comments precede it (no list enumeration is involved).
|
||||||
|
comments = [comment(i, f"prior {i}", acting) for i in range(1, 51)]
|
||||||
|
elif mode == "no-op-concurrent":
|
||||||
|
# A concurrent SAME-IDENTITY comment with the IDENTICAL body already exists.
|
||||||
|
# The wrapper's own write will be a no-op; it must still fail closed because
|
||||||
|
# no created id is returned — it must not scan and accept this record.
|
||||||
|
comments = [comment(55, body, acting)]
|
||||||
|
else: # author-mismatch
|
||||||
|
comments = []
|
||||||
|
|
||||||
|
with open(sys.argv[1], "w", encoding="utf-8") as handle:
|
||||||
|
json.dump(comments, handle)
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
|
run_comment() {
|
||||||
|
local mode="$1"
|
||||||
|
shift
|
||||||
|
: > "$TEA_LOG"
|
||||||
|
: > "$CURL_LOG"
|
||||||
|
: > "$AUTH_LOG"
|
||||||
|
: > "$OUTPUT_FILE"
|
||||||
|
seed_state "$mode"
|
||||||
|
(
|
||||||
|
cd "$REPO_DIR"
|
||||||
|
PATH="$BIN_DIR:$PATH" \
|
||||||
|
TMPDIR="$TMP_SCRATCH" \
|
||||||
|
XDG_CONFIG_HOME="$XDG_DIR" \
|
||||||
|
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
|
||||||
|
ISSUE_COMMENT_TEA_LOG="$TEA_LOG" \
|
||||||
|
ISSUE_COMMENT_CURL_LOG="$CURL_LOG" \
|
||||||
|
ISSUE_COMMENT_AUTH_LOG="$AUTH_LOG" \
|
||||||
|
ISSUE_COMMENT_STATE="$STATE_FILE" \
|
||||||
|
ISSUE_COMMENT_TEST_MODE="$mode" \
|
||||||
|
ISSUE_COMMENT_ACTING_LOGIN="$ACTING_LOGIN" \
|
||||||
|
ISSUE_COMMENT_FOREIGN_LOGIN="$FOREIGN_LOGIN" \
|
||||||
|
ISSUE_COMMENT_OVERRIDE_LOGIN="$OVERRIDE_LOGIN" \
|
||||||
|
ISSUE_COMMENT_CROSS_HOST_LOGIN="$CROSS_HOST_LOGIN" \
|
||||||
|
ISSUE_COMMENT_DEFAULT_TOKEN="$DEFAULT_TOKEN" \
|
||||||
|
ISSUE_COMMENT_OVERRIDE_TOKEN="$OVERRIDE_TOKEN" \
|
||||||
|
ISSUE_COMMENT_CROSS_HOST_TOKEN="$CROSS_HOST_TOKEN" \
|
||||||
|
ISSUE_COMMENT_REPO_SLUG="$REPO_SLUG" \
|
||||||
|
ISSUE_COMMENT_API_BASE="$API_BASE" \
|
||||||
|
ISSUE_COMMENT_API_ROOT="$API_ROOT" \
|
||||||
|
"$SCRIPT_DIR/issue-comment.sh" -i "$ISSUE_NUMBER" -c "$BODY" "$@"
|
||||||
|
) > "$OUTPUT_FILE" 2>&1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Assert the wrapper left no scratch temp files behind in TMPDIR (POST/GET
|
||||||
|
# request bodies + metadata). Called after both success and failure paths so a
|
||||||
|
# clobbered/leaked RETURN trap is caught on every exit route.
|
||||||
|
assert_no_temp_leak() {
|
||||||
|
local context="$1" leaked
|
||||||
|
leaked=$(find "$TMP_SCRATCH" -type f -name 'mosaic-issue-comment-*' 2>/dev/null || true)
|
||||||
|
if [[ -n "$leaked" ]]; then
|
||||||
|
echo "FAIL: issue-comment temp files leaked ($context):" >&2
|
||||||
|
printf '%s\n' "$leaked" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Case 1: a genuine REST create (id 51) is verified end to end via its exact
|
||||||
|
# provider-returned id — no list enumeration is involved.
|
||||||
|
run_comment fresh-success
|
||||||
|
grep -q 'Added and verified comment on Gitea issue #7 (comment ID 51)' "$OUTPUT_FILE"
|
||||||
|
# The write is a REST POST, never a tea comment.
|
||||||
|
grep -q "^POST $API_BASE/issues/7/comments$" "$CURL_LOG"
|
||||||
|
if grep -Eq '^comment |^issue comment ' "$TEA_LOG"; then
|
||||||
|
echo "FAIL: wrapper wrote a comment via tea instead of REST" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
# Read-back is a DIRECT GET of the exact created id.
|
||||||
|
grep -q "^GET $API_BASE/issues/comments/51$" "$CURL_LOG"
|
||||||
|
# Acting identity resolved via GET /user.
|
||||||
|
grep -q "^GET $API_ROOT/user$" "$CURL_LOG"
|
||||||
|
# No comment-list enumeration is performed — the exact-id GET is authoritative.
|
||||||
|
if grep -Eq "^GET $API_BASE/issues/7/comments(\?|$)" "$CURL_LOG"; then
|
||||||
|
echo "FAIL: wrapper performed a redundant comment-list enumeration" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
# Default path (no --login): the host credential fallback resolves, and the
|
||||||
|
# write is performed AND self-verified under the host-default acting identity.
|
||||||
|
grep -q "^POST $API_BASE/issues/7/comments $ACTING_LOGIN$" "$AUTH_LOG"
|
||||||
|
grep -q "^GET $API_BASE/issues/comments/51 $ACTING_LOGIN$" "$AUTH_LOG"
|
||||||
|
# Success path leaves no scratch temp files behind.
|
||||||
|
assert_no_temp_leak "fresh-success"
|
||||||
|
|
||||||
|
# Case 2: a no-op write with a concurrent SAME-IDENTITY, same-body comment
|
||||||
|
# already present must FAIL CLOSED — the closed concurrency window.
|
||||||
|
if run_comment no-op-concurrent; then
|
||||||
|
echo "FAIL: wrapper reported success when its write no-opped but a concurrent same-identity comment existed" >&2
|
||||||
|
cat "$OUTPUT_FILE" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if grep -q 'Added and verified comment' "$OUTPUT_FILE"; then
|
||||||
|
echo "FAIL: wrapper accepted a concurrent record for a no-op write (window not closed)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
# It must NOT have fallen back to a list scan that could find the concurrent id.
|
||||||
|
if grep -q "^GET $API_BASE/issues/comments/55$" "$CURL_LOG"; then
|
||||||
|
echo "FAIL: wrapper read back the concurrent comment id 55 (illegitimate fallback)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Case 3: a created record NOT authored by the acting identity must FAIL CLOSED.
|
||||||
|
if run_comment author-mismatch; then
|
||||||
|
echo "FAIL: wrapper accepted a created comment authored by a different identity" >&2
|
||||||
|
cat "$OUTPUT_FILE" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if grep -q 'Added and verified comment' "$OUTPUT_FILE"; then
|
||||||
|
echo "FAIL: read-back did not enforce acting-identity authorship" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
# Failure-after-read-back path must ALSO leave no scratch temp files behind
|
||||||
|
# (proves the RETURN traps clean up on the error-return route, not just success).
|
||||||
|
assert_no_temp_leak "author-mismatch"
|
||||||
|
|
||||||
|
# Case 4: a RESOLVABLE --login override — the write, the /user identity lookup,
|
||||||
|
# and the read-back must ALL be performed under THAT login's token/identity, not
|
||||||
|
# the host default. The override login has id 1 (empty seed).
|
||||||
|
run_comment override-success --login "$OVERRIDE_LOGIN"
|
||||||
|
grep -q 'Added and verified comment on Gitea issue #7 (comment ID 1)' "$OUTPUT_FILE"
|
||||||
|
grep -q "^GET $API_ROOT/user $OVERRIDE_LOGIN$" "$AUTH_LOG"
|
||||||
|
grep -q "^POST $API_BASE/issues/7/comments $OVERRIDE_LOGIN$" "$AUTH_LOG"
|
||||||
|
grep -q "^GET $API_BASE/issues/comments/1 $OVERRIDE_LOGIN$" "$AUTH_LOG"
|
||||||
|
# The host-default identity must NOT have performed ANY request in this run.
|
||||||
|
if grep -q " $ACTING_LOGIN\$" "$AUTH_LOG"; then
|
||||||
|
echo "FAIL: an explicit --login override request was performed under the host default identity" >&2
|
||||||
|
cat "$AUTH_LOG" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Case 5: an UNRESOLVABLE --login override (name absent from tea config) must
|
||||||
|
# FAIL CLOSED — no silent downgrade to the host default identity: nonzero exit,
|
||||||
|
# no success line, and NO write performed.
|
||||||
|
if run_comment override-unresolvable --login "nonexistent-typo-login"; then
|
||||||
|
echo "FAIL: unresolvable --login override did not fail closed" >&2
|
||||||
|
cat "$OUTPUT_FILE" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if grep -q 'Added and verified comment' "$OUTPUT_FILE"; then
|
||||||
|
echo "FAIL: unresolvable --login override reported success" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if grep -q "^POST $API_BASE/issues/7/comments" "$CURL_LOG"; then
|
||||||
|
echo "FAIL: unresolvable --login override still performed a write" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
# And it must not have silently fallen back to the host default identity.
|
||||||
|
if grep -q " $ACTING_LOGIN\$" "$AUTH_LOG"; then
|
||||||
|
echo "FAIL: unresolvable --login override fell back to the host default identity" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Case 6: a --login override that IS present in tea config but whose URL is a
|
||||||
|
# DIFFERENT host than the repo remote must FAIL CLOSED (host-bound selection).
|
||||||
|
# The cross-host token must NEVER be sent to the repo host, and no write occurs.
|
||||||
|
if run_comment cross-host --login "$CROSS_HOST_LOGIN"; then
|
||||||
|
echo "FAIL: cross-host --login override did not fail closed" >&2
|
||||||
|
cat "$OUTPUT_FILE" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if grep -q 'Added and verified comment' "$OUTPUT_FILE"; then
|
||||||
|
echo "FAIL: cross-host --login override reported success" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
# The cross-host credential must not have performed ANY request against the repo
|
||||||
|
# host — no request may be attributed to the cross-host identity.
|
||||||
|
if grep -q " $CROSS_HOST_LOGIN\$" "$AUTH_LOG"; then
|
||||||
|
echo "FAIL: cross-host credential was sent to the repo host (cross-host leak)" >&2
|
||||||
|
cat "$AUTH_LOG" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if grep -q "^POST $API_BASE/issues/7/comments" "$CURL_LOG"; then
|
||||||
|
echo "FAIL: cross-host --login override still performed a write" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
# It must not have silently downgraded to the host default identity either.
|
||||||
|
if grep -q " $ACTING_LOGIN\$" "$AUTH_LOG"; then
|
||||||
|
echo "FAIL: cross-host --login override fell back to the host default identity" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
assert_no_temp_leak "cross-host"
|
||||||
|
|
||||||
|
echo "issue-comment.sh REST create + exact-id read-back regression passed"
|
||||||
@@ -0,0 +1,696 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# 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.
|
||||||
|
#
|
||||||
|
# #865 Round-4: the curl stub also maps the presented bearer token to the
|
||||||
|
# identity it authenticates as and logs it per request, so tests can prove
|
||||||
|
# credential attribution. An explicit --login override must drive the entire
|
||||||
|
# write→read-back chain under THAT login's token (resolvable case) or FAIL
|
||||||
|
# CLOSED (unresolvable case) — never silently downgrade to the host-default
|
||||||
|
# identity. The host-default best-effort fallback is reserved for the
|
||||||
|
# no-override default path.
|
||||||
|
#
|
||||||
|
# #865 Round-5: the exact-id read-back is the SOLE authority — the wrapper does
|
||||||
|
# NO follow-up list enumeration (the stub exposes no review/comment list
|
||||||
|
# endpoint, so a residual enumeration would fail the run). A --login override is
|
||||||
|
# host-bound: an override configured for a DIFFERENT host than the repo remote
|
||||||
|
# FAILS CLOSED rather than leaking a cross-host credential. And every run leaves
|
||||||
|
# no scratch temp files behind on any exit path (POST/GET bodies + metadata).
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
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"
|
||||||
|
AUTH_LOG="$WORK_DIR/auth.log"
|
||||||
|
OUTPUT_FILE="$WORK_DIR/output.log"
|
||||||
|
CREDENTIALS_FILE="$WORK_DIR/credentials.json"
|
||||||
|
# A dedicated scratch dir the wrapper is pointed at via TMPDIR, so the leak
|
||||||
|
# check can assert every POST/GET body + metadata temp file is cleaned up.
|
||||||
|
TMP_SCRATCH="$WORK_DIR/scratch"
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
rm -rf "$WORK_DIR"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
ACTING_LOGIN="review-bot"
|
||||||
|
FOREIGN_LOGIN="other-writer"
|
||||||
|
HEAD_SHA="HEADSHA_FEEDFACE"
|
||||||
|
# A dedicated per-role --login override identity with its own token in tea's
|
||||||
|
# config (the author-not-equal-reviewer hardening path).
|
||||||
|
OVERRIDE_LOGIN="primary-reviewer"
|
||||||
|
DEFAULT_TOKEN="test-only-placeholder"
|
||||||
|
OVERRIDE_TOKEN="override-token-placeholder"
|
||||||
|
# A --login override whose tea config URL points at a DIFFERENT Gitea host than
|
||||||
|
# the repo remote (git.mosaicstack.dev). Host-bound selection must reject it
|
||||||
|
# rather than send its token cross-host.
|
||||||
|
CROSS_HOST_LOGIN="foreign-host-reviewer"
|
||||||
|
CROSS_HOST_TOKEN="cross-host-token-placeholder"
|
||||||
|
|
||||||
|
mkdir -p "$REPO_DIR" "$BIN_DIR" "$XDG_DIR" "$STATE_DIR" "$TMP_SCRATCH"
|
||||||
|
git -C "$REPO_DIR" init -q
|
||||||
|
git -C "$REPO_DIR" remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git
|
||||||
|
|
||||||
|
# tea config: the override login carries its own token here. The default login
|
||||||
|
# name ("mosaicstack") is deliberately absent, so the no-override default path
|
||||||
|
# resolves via the host credential fallback while an explicit --login must
|
||||||
|
# resolve from this file or fail closed. A second login is configured for a
|
||||||
|
# DIFFERENT host to exercise host-bound rejection.
|
||||||
|
mkdir -p "$XDG_DIR/tea"
|
||||||
|
OVERRIDE_LOGIN="$OVERRIDE_LOGIN" OVERRIDE_TOKEN="$OVERRIDE_TOKEN" \
|
||||||
|
CROSS_HOST_LOGIN="$CROSS_HOST_LOGIN" CROSS_HOST_TOKEN="$CROSS_HOST_TOKEN" \
|
||||||
|
python3 - "$XDG_DIR/tea/config.yml" <<'PY'
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
with open(sys.argv[1], "w", encoding="utf-8") as handle:
|
||||||
|
handle.write("logins:\n")
|
||||||
|
handle.write(f" - name: {os.environ['OVERRIDE_LOGIN']}\n")
|
||||||
|
handle.write(" url: https://git.mosaicstack.dev\n")
|
||||||
|
handle.write(f" token: {os.environ['OVERRIDE_TOKEN']}\n")
|
||||||
|
handle.write(f" - name: {os.environ['CROSS_HOST_LOGIN']}\n")
|
||||||
|
handle.write(" url: https://git.uscllc.com\n")
|
||||||
|
handle.write(f" token: {os.environ['CROSS_HOST_TOKEN']}\n")
|
||||||
|
PY
|
||||||
|
|
||||||
|
write_credentials() {
|
||||||
|
local configured_url="$1"
|
||||||
|
CONFIGURED_GITEA_URL="$configured_url" 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. 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
|
||||||
|
|
||||||
|
printf '%s\n' "$*" >> "$PR_REVIEW_TEA_LOG"
|
||||||
|
|
||||||
|
if [[ "$*" == "login list --output json" ]]; then
|
||||||
|
printf '[{"name":"mosaicstack","url":"%s"}]\n' "$PR_REVIEW_LOGIN_URL"
|
||||||
|
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 review/comment
|
||||||
|
# state.
|
||||||
|
cat > "$BIN_DIR/curl" <<'SH'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
output_file=""
|
||||||
|
method="GET"
|
||||||
|
payload=""
|
||||||
|
url=""
|
||||||
|
auth_token=""
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
-o) output_file="$2"; shift 2 ;;
|
||||||
|
-H)
|
||||||
|
[[ "$2" == Authorization:* ]] && auth_token="${2##* }"
|
||||||
|
shift 2 ;;
|
||||||
|
-w) shift 2 ;;
|
||||||
|
-X) method="$2"; shift 2 ;;
|
||||||
|
-d|--data) 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"
|
||||||
|
|
||||||
|
# Map the presented bearer token to the identity it authenticates as (as Gitea's
|
||||||
|
# /user does). The write, /user lookup, and read-back must all carry the SAME
|
||||||
|
# token, so the identity logged here reveals which credential performed each
|
||||||
|
# request — proving an explicit --login override is honored, not downgraded.
|
||||||
|
acting_identity=""
|
||||||
|
case "$auth_token" in
|
||||||
|
"$PR_REVIEW_DEFAULT_TOKEN") acting_identity="$PR_REVIEW_ACTING_LOGIN" ;;
|
||||||
|
"$PR_REVIEW_OVERRIDE_TOKEN") acting_identity="$PR_REVIEW_OVERRIDE_LOGIN" ;;
|
||||||
|
"$PR_REVIEW_CROSS_HOST_TOKEN") acting_identity="$PR_REVIEW_CROSS_HOST_LOGIN" ;;
|
||||||
|
esac
|
||||||
|
printf '%s %s %s\n' "$method" "$path" "${acting_identity:-<unauthenticated>}" >> "$PR_REVIEW_AUTH_LOG"
|
||||||
|
|
||||||
|
write_response() {
|
||||||
|
local status="$1" body="$2"
|
||||||
|
[[ -n "$output_file" ]] || exit 96
|
||||||
|
printf '%s' "$body" > "$output_file"
|
||||||
|
printf '%s' "$status"
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
[[ -n "$acting_identity" ]] || { write_response 401 '{"message":"unauthenticated"}'; exit 0; }
|
||||||
|
write_response 200 "$(PR_REVIEW_LOGIN="$acting_identity" python3 - <<'PY'
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
print(json.dumps({"login": os.environ["PR_REVIEW_LOGIN"]}))
|
||||||
|
PY
|
||||||
|
)"
|
||||||
|
elif [[ "$method" == "GET" && "$path" == "$PR_REVIEW_EXPECTED_API_BASE/pulls/123" ]]; then
|
||||||
|
write_response 200 "$(PR_REVIEW_HEAD_SHA="$PR_REVIEW_HEAD_SHA" python3 - <<'PY'
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
print(json.dumps({"head": {"sha": os.environ["PR_REVIEW_HEAD_SHA"]}}))
|
||||||
|
PY
|
||||||
|
)"
|
||||||
|
elif [[ "$method" == "POST" && "$path" == "$PR_REVIEW_EXPECTED_API_BASE/pulls/123/reviews" ]]; then
|
||||||
|
printf '%s' "$payload" > "$PR_REVIEW_SUBMIT_PAYLOAD"
|
||||||
|
emit "$(PR_REVIEW_ACTING_LOGIN="${acting_identity:-$PR_REVIEW_ACTING_LOGIN}" PR_REVIEW_PAYLOAD="$payload" python3 - <<'PY'
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
state_path = os.environ["PR_REVIEW_REVIEWS"]
|
||||||
|
mode = os.environ["PR_REVIEW_TEST_MODE"]
|
||||||
|
acting = os.environ["PR_REVIEW_ACTING_LOGIN"]
|
||||||
|
foreign = os.environ["PR_REVIEW_FOREIGN_LOGIN"]
|
||||||
|
submitted = json.loads(os.environ["PR_REVIEW_PAYLOAD"])
|
||||||
|
|
||||||
|
with open(state_path, encoding="utf-8") as handle:
|
||||||
|
reviews = json.load(handle)
|
||||||
|
|
||||||
|
# no-op-concurrent-review: the wrapper's own submit is SUPPRESSED (200, no
|
||||||
|
# created object) even though a concurrent same-identity, same-state review at
|
||||||
|
# the same head already exists. Nothing is persisted; no created id to verify.
|
||||||
|
if mode == "no-op-concurrent-review":
|
||||||
|
print("200")
|
||||||
|
print(json.dumps({}))
|
||||||
|
raise SystemExit(0)
|
||||||
|
|
||||||
|
author = foreign if mode == "author-mismatch-review" else acting
|
||||||
|
new_id = (max((r["id"] for r in reviews), default=0)) + 1
|
||||||
|
record = {
|
||||||
|
"id": new_id,
|
||||||
|
"state": submitted.get("event"),
|
||||||
|
"commit_id": submitted.get("commit_id"),
|
||||||
|
"body": submitted.get("body"),
|
||||||
|
"user": {"login": author},
|
||||||
|
}
|
||||||
|
reviews.append(record)
|
||||||
|
with open(state_path, "w", encoding="utf-8") as handle:
|
||||||
|
json.dump(reviews, handle)
|
||||||
|
print("201")
|
||||||
|
print(json.dumps(record))
|
||||||
|
PY
|
||||||
|
)"
|
||||||
|
elif [[ "$method" == "GET" && "$path" == "$PR_REVIEW_EXPECTED_API_BASE"/pulls/123/reviews/* ]]; then
|
||||||
|
emit "$(PR_REVIEW_GET_ID="${path##*/}" python3 - <<'PY'
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
state_path = os.environ["PR_REVIEW_REVIEWS"]
|
||||||
|
wanted = int(os.environ["PR_REVIEW_GET_ID"])
|
||||||
|
with open(state_path, encoding="utf-8") as handle:
|
||||||
|
reviews = json.load(handle)
|
||||||
|
match = next((r for r in reviews if r["id"] == wanted), None)
|
||||||
|
if match is None:
|
||||||
|
print("404")
|
||||||
|
print(json.dumps({"message": "not found"}))
|
||||||
|
else:
|
||||||
|
print("200")
|
||||||
|
print(json.dumps(match))
|
||||||
|
PY
|
||||||
|
)"
|
||||||
|
elif [[ "$method" == "POST" && "$path" == "$PR_REVIEW_EXPECTED_API_BASE/issues/123/comments" ]]; then
|
||||||
|
case "$mode" in
|
||||||
|
write-transport-failure)
|
||||||
|
echo "simulated transport failure" >&2
|
||||||
|
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"]
|
||||||
|
web_base = os.environ["PR_REVIEW_WEB_BASE"]
|
||||||
|
body = json.loads(os.environ["PR_REVIEW_PAYLOAD"]).get("body")
|
||||||
|
# REAL Gitea shape for a comment posted to a PR's conversation
|
||||||
|
# (/issues/{n}/comments on a PR): pull_request_url is the WEB pulls path and
|
||||||
|
# issue_url is left empty. This is what the wrapper must tolerate — it must NOT
|
||||||
|
# require an API-shaped issue_url.
|
||||||
|
record = {
|
||||||
|
"id": 456,
|
||||||
|
"body": body,
|
||||||
|
"user": {"login": acting},
|
||||||
|
"issue_url": "",
|
||||||
|
"pull_request_url": f"{web_base}/pulls/123",
|
||||||
|
}
|
||||||
|
with open(state_path, "w", encoding="utf-8") as handle:
|
||||||
|
json.dump([record], handle)
|
||||||
|
print("201")
|
||||||
|
print(json.dumps(record))
|
||||||
|
PY
|
||||||
|
)"
|
||||||
|
;;
|
||||||
|
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 == "many-prior-approve":
|
||||||
|
# 50 pre-existing reviews already exist; the review this run submits becomes
|
||||||
|
# id 51, proving exact-id read-back works regardless of how many reviews
|
||||||
|
# precede it (no list enumeration is involved).
|
||||||
|
reviews = [review(i, "COMMENT", "oldsha0000", acting) for i in range(1, 51)]
|
||||||
|
elif mode == "no-op-concurrent-review":
|
||||||
|
# A concurrent SAME-IDENTITY APPROVED review at the CURRENT head already
|
||||||
|
# exists. The wrapper's own submit will be a no-op; it must fail closed
|
||||||
|
# because no created id is returned — it must not scan and accept this one.
|
||||||
|
reviews = [review(77, "APPROVED", head, acting)]
|
||||||
|
else:
|
||||||
|
reviews = [review(100, "COMMENT", "oldsha0000", acting)]
|
||||||
|
|
||||||
|
with open(sys.argv[1], "w", encoding="utf-8") as handle:
|
||||||
|
json.dump(reviews, handle)
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
|
run_review() {
|
||||||
|
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 login_override="${7:-}"
|
||||||
|
local expected_api_base="${configured_url%/}/api/v1/repos/$expected_repo"
|
||||||
|
local expected_api_root="${configured_url%/}/api/v1"
|
||||||
|
local expected_web_base="${configured_url%/}/$expected_repo"
|
||||||
|
git -C "$REPO_DIR" remote set-url origin "$remote_url"
|
||||||
|
write_credentials "$configured_url"
|
||||||
|
: > "$TEA_LOG"
|
||||||
|
: > "$CURL_LOG"
|
||||||
|
: > "$AUTH_LOG"
|
||||||
|
: > "$OUTPUT_FILE"
|
||||||
|
seed_state "$mode"
|
||||||
|
(
|
||||||
|
cd "$REPO_DIR"
|
||||||
|
PATH="$BIN_DIR:$PATH" \
|
||||||
|
TMPDIR="$TMP_SCRATCH" \
|
||||||
|
XDG_CONFIG_HOME="$XDG_DIR" \
|
||||||
|
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
|
||||||
|
PR_REVIEW_TEA_LOG="$TEA_LOG" \
|
||||||
|
PR_REVIEW_LOGIN_URL="${configured_url%/}" \
|
||||||
|
PR_REVIEW_CURL_LOG="$CURL_LOG" \
|
||||||
|
PR_REVIEW_AUTH_LOG="$AUTH_LOG" \
|
||||||
|
PR_REVIEW_REVIEWS="$REVIEWS_FILE" \
|
||||||
|
PR_REVIEW_COMMENTS="$COMMENTS_FILE" \
|
||||||
|
PR_REVIEW_SUBMIT_PAYLOAD="$SUBMIT_PAYLOAD_FILE" \
|
||||||
|
PR_REVIEW_TEST_MODE="$mode" \
|
||||||
|
PR_REVIEW_EXPECTED_BODY="$comment" \
|
||||||
|
PR_REVIEW_EXPECTED_API_BASE="$expected_api_base" \
|
||||||
|
PR_REVIEW_API_ROOT="$expected_api_root" \
|
||||||
|
PR_REVIEW_WEB_BASE="$expected_web_base" \
|
||||||
|
PR_REVIEW_HEAD_SHA="$HEAD_SHA" \
|
||||||
|
PR_REVIEW_ACTING_LOGIN="$ACTING_LOGIN" \
|
||||||
|
PR_REVIEW_FOREIGN_LOGIN="$FOREIGN_LOGIN" \
|
||||||
|
PR_REVIEW_OVERRIDE_LOGIN="$OVERRIDE_LOGIN" \
|
||||||
|
PR_REVIEW_CROSS_HOST_LOGIN="$CROSS_HOST_LOGIN" \
|
||||||
|
PR_REVIEW_DEFAULT_TOKEN="$DEFAULT_TOKEN" \
|
||||||
|
PR_REVIEW_OVERRIDE_TOKEN="$OVERRIDE_TOKEN" \
|
||||||
|
PR_REVIEW_CROSS_HOST_TOKEN="$CROSS_HOST_TOKEN" \
|
||||||
|
"$SCRIPT_DIR/pr-review.sh" -n 123 -a "$action" ${comment:+-c "$comment"} ${login_override:+--login "$login_override"}
|
||||||
|
) > "$OUTPUT_FILE" 2>&1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Assert the wrapper left no scratch temp files behind in TMPDIR (POST/GET
|
||||||
|
# request bodies + metadata). Called after both success and failure paths so a
|
||||||
|
# clobbered/leaked RETURN trap is caught on every exit route.
|
||||||
|
assert_no_temp_leak() {
|
||||||
|
local context="$1" leaked
|
||||||
|
leaked=$(find "$TMP_SCRATCH" -type f -name 'mosaic-pr-review-*' 2>/dev/null || true)
|
||||||
|
if [[ -n "$leaked" ]]; then
|
||||||
|
echo "FAIL: pr-review temp files leaked ($context):" >&2
|
||||||
|
printf '%s\n' "$leaked" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_no_tea_write() {
|
||||||
|
# tea must only ever be used for the login list, never to write.
|
||||||
|
if grep -qvE '^login list --output json$' "$TEA_LOG"; then
|
||||||
|
echo "FAIL: wrapper invoked tea for something other than the login list" >&2
|
||||||
|
cat "$TEA_LOG" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Case 1: a plain approve submits a review via REST and verifies it by its exact
|
||||||
|
# provider-returned id (id 101), attributed to the acting identity, pinned to
|
||||||
|
# the PR head, with no separate comment.
|
||||||
|
run_review approve approve
|
||||||
|
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"
|
||||||
|
# No review-list enumeration is performed — the exact-id GET is authoritative.
|
||||||
|
if grep -Eq '^GET https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/pulls/123/reviews(\?|$)' "$CURL_LOG"; then
|
||||||
|
echo "FAIL: wrapper performed a redundant review-list enumeration" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
# No-override default path: the write, /user lookup, and read-back all resolve
|
||||||
|
# via the host-default credential and authenticate as the acting identity.
|
||||||
|
grep -q "^POST https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/pulls/123/reviews $ACTING_LOGIN\$" "$AUTH_LOG"
|
||||||
|
grep -q "^GET https://git.mosaicstack.dev/api/v1/user $ACTING_LOGIN\$" "$AUTH_LOG"
|
||||||
|
assert_no_tea_write
|
||||||
|
assert_no_temp_leak "approve"
|
||||||
|
# The submitted review payload carries the event and the PR head commit_id.
|
||||||
|
PR_REVIEW_HEAD_SHA="$HEAD_SHA" python3 - "$SUBMIT_PAYLOAD_FILE" <<'PY'
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
payload = json.load(open(sys.argv[1], encoding="utf-8"))
|
||||||
|
assert payload["event"] == "APPROVED", payload
|
||||||
|
assert payload["commit_id"] == os.environ["PR_REVIEW_HEAD_SHA"], payload
|
||||||
|
PY
|
||||||
|
# A plain approve (no body) must not POST a comment.
|
||||||
|
if grep -q '/issues/123/comments' "$CURL_LOG"; then
|
||||||
|
echo "FAIL: plain approve unexpectedly posted a comment" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Case 2: a submitted review NOT authored by the acting identity must FAIL
|
||||||
|
# CLOSED — the exact-id read-back enforces authorship.
|
||||||
|
if run_review author-mismatch-review approve; then
|
||||||
|
echo "FAIL: approve accepted a review authored by a different identity" >&2
|
||||||
|
cat "$OUTPUT_FILE" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if grep -q 'Approved and verified' "$OUTPUT_FILE"; then
|
||||||
|
echo "FAIL: read-back did not enforce acting-identity authorship" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
# Failure-after-read-back path must ALSO leave no scratch temp files behind.
|
||||||
|
assert_no_temp_leak "author-mismatch-review"
|
||||||
|
|
||||||
|
# Case 3: a no-op submit with a concurrent SAME-IDENTITY, same-state review at
|
||||||
|
# the current head already present must FAIL CLOSED — the closed concurrency
|
||||||
|
# window. The wrapper must not read back (or accept) the concurrent id 77.
|
||||||
|
if run_review no-op-concurrent-review approve; then
|
||||||
|
echo "FAIL: approve reported success when its submit no-opped but a concurrent review existed" >&2
|
||||||
|
cat "$OUTPUT_FILE" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if grep -q 'Approved and verified' "$OUTPUT_FILE"; then
|
||||||
|
echo "FAIL: approve accepted a concurrent review for a no-op submit (window not closed)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if grep -q '/pulls/123/reviews/77$' "$CURL_LOG"; then
|
||||||
|
echo "FAIL: wrapper read back the concurrent review id 77 (illegitimate fallback)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Case 4: a genuine matching review (id 51) created after 50 pre-existing reviews
|
||||||
|
# is still verified by its EXACT provider-returned id — no list enumeration is
|
||||||
|
# needed regardless of how many reviews precede it.
|
||||||
|
run_review many-prior-approve approve
|
||||||
|
grep -q 'Approved and verified Gitea PR #123 (review ID 51)' "$OUTPUT_FILE"
|
||||||
|
grep -q '^GET https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/pulls/123/reviews/51$' "$CURL_LOG"
|
||||||
|
if grep -Eq '^GET https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/pulls/123/reviews(\?|$)' "$CURL_LOG"; then
|
||||||
|
echo "FAIL: wrapper performed a redundant review-list enumeration" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Case 5: an approve WITH a body carries that body in the review submit itself —
|
||||||
|
# there is no separate detached comment POST.
|
||||||
|
run_review approve approve approve-note
|
||||||
|
grep -q 'Approved and verified Gitea PR #123 (review ID 101)' "$OUTPUT_FILE"
|
||||||
|
PR_REVIEW_EXPECTED_BODY="approve-note" python3 - "$SUBMIT_PAYLOAD_FILE" <<'PY'
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
payload = json.load(open(sys.argv[1], encoding="utf-8"))
|
||||||
|
assert payload["body"] == os.environ["PR_REVIEW_EXPECTED_BODY"], payload
|
||||||
|
PY
|
||||||
|
if grep -q '/issues/123/comments' "$CURL_LOG"; then
|
||||||
|
echo "FAIL: approve-with-body posted a separate comment instead of carrying the body on the review" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Case 6: request-changes requires a body and carries it on the REQUEST_CHANGES
|
||||||
|
# review submit.
|
||||||
|
run_review request-changes request-changes changes-required
|
||||||
|
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"
|
||||||
|
assert_no_tea_write
|
||||||
|
assert_no_temp_leak "comment-success"
|
||||||
|
|
||||||
|
run_review http-success comment durable-body http://git.mosaicstack.dev
|
||||||
|
grep -q '^POST http://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/issues/123/comments$' "$CURL_LOG"
|
||||||
|
grep -q '^GET http://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/issues/comments/456$' "$CURL_LOG"
|
||||||
|
|
||||||
|
run_review prefix-success comment durable-body https://git.mosaicstack.dev/gitea/
|
||||||
|
grep -q '^POST https://git.mosaicstack.dev/gitea/api/v1/repos/mosaicstack/stack/issues/123/comments$' "$CURL_LOG"
|
||||||
|
grep -q '^GET https://git.mosaicstack.dev/gitea/api/v1/repos/mosaicstack/stack/issues/comments/456$' "$CURL_LOG"
|
||||||
|
|
||||||
|
run_review subpath-success comment durable-body https://git.example/gitea https://git.example/gitea/owner/repo.git owner/repo
|
||||||
|
grep -q '^POST https://git.example/gitea/api/v1/repos/owner/repo/issues/123/comments$' "$CURL_LOG"
|
||||||
|
grep -q '^GET https://git.example/gitea/api/v1/repos/owner/repo/issues/comments/456$' "$CURL_LOG"
|
||||||
|
if grep -q '/repos/gitea/owner/repo/' "$CURL_LOG"; then
|
||||||
|
echo "Configured Gitea path prefix leaked into the repository slug" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
run_review port-success comment durable-body http://git.example:3000 http://git.example:3000/owner/repo.git owner/repo
|
||||||
|
grep -q '^POST http://git.example:3000/api/v1/repos/owner/repo/issues/123/comments$' "$CURL_LOG"
|
||||||
|
grep -q '^GET http://git.example:3000/api/v1/repos/owner/repo/issues/comments/456$' "$CURL_LOG"
|
||||||
|
|
||||||
|
run_review scp-ssh-success comment durable-body https://git.example git@git.example:owner/repo.git owner/repo
|
||||||
|
grep -q '^POST https://git.example/api/v1/repos/owner/repo/issues/123/comments$' "$CURL_LOG"
|
||||||
|
|
||||||
|
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
|
||||||
|
fi
|
||||||
|
if run_review write-http-failure comment durable-body; then
|
||||||
|
echo "Expected non-201 provider write to return nonzero" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if run_review readback-failure comment durable-body; then
|
||||||
|
echo "Expected mismatched provider read-back to return nonzero" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if grep -q 'Added and verified comment' "$OUTPUT_FILE"; then
|
||||||
|
echo "Read-back mismatch reported durable success" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Case 8 (#865 Round-4): a RESOLVABLE explicit --login override must attribute
|
||||||
|
# the entire write→read-back chain to THAT login's token/identity, never the
|
||||||
|
# host-default identity. The override login carries its own token in the tea
|
||||||
|
# config, so /user, the review POST, and the exact-id read-back all authenticate
|
||||||
|
# as the override identity — and NOTHING is performed under the default identity.
|
||||||
|
run_review override-success approve "" https://git.mosaicstack.dev \
|
||||||
|
https://git.mosaicstack.dev/mosaicstack/stack.git mosaicstack/stack "$OVERRIDE_LOGIN"
|
||||||
|
grep -q 'Approved and verified Gitea PR #123 (review ID 101)' "$OUTPUT_FILE"
|
||||||
|
grep -q "^GET https://git.mosaicstack.dev/api/v1/user $OVERRIDE_LOGIN\$" "$AUTH_LOG"
|
||||||
|
grep -q "^POST https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/pulls/123/reviews $OVERRIDE_LOGIN\$" "$AUTH_LOG"
|
||||||
|
grep -q "^GET https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/pulls/123/reviews/101 $OVERRIDE_LOGIN\$" "$AUTH_LOG"
|
||||||
|
if grep -q " $ACTING_LOGIN\$" "$AUTH_LOG"; then
|
||||||
|
echo "FAIL: an explicit --login override was silently downgraded to the host-default identity" >&2
|
||||||
|
cat "$AUTH_LOG" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
assert_no_tea_write
|
||||||
|
|
||||||
|
# Case 9 (#865 Round-4): an UNRESOLVABLE explicit --login override (a name absent
|
||||||
|
# from the tea config) must FAIL CLOSED — nonzero exit, no success line, no review
|
||||||
|
# POST, and above all NO request performed under the host-default identity. The
|
||||||
|
# host-default best-effort fallback is reserved for the no-override path only.
|
||||||
|
if run_review override-unresolvable approve "" https://git.mosaicstack.dev \
|
||||||
|
https://git.mosaicstack.dev/mosaicstack/stack.git mosaicstack/stack "nonexistent-typo-login"; then
|
||||||
|
echo "FAIL: an unresolvable --login override was not rejected (silently used the host default)" >&2
|
||||||
|
cat "$OUTPUT_FILE" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if grep -q 'Approved and verified' "$OUTPUT_FILE"; then
|
||||||
|
echo "FAIL: unresolvable --login override reported success" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if grep -q '/pulls/123/reviews ' "$AUTH_LOG" && grep -qE '^POST .*/pulls/123/reviews ' "$AUTH_LOG"; then
|
||||||
|
echo "FAIL: unresolvable --login override performed a review POST" >&2
|
||||||
|
cat "$AUTH_LOG" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if grep -q " $ACTING_LOGIN\$" "$AUTH_LOG"; then
|
||||||
|
echo "FAIL: unresolvable --login override fell back to the host-default identity" >&2
|
||||||
|
cat "$AUTH_LOG" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Case 10 (#865 Round-5): a --login override that IS present in tea config but
|
||||||
|
# whose URL is a DIFFERENT host than the repo remote must FAIL CLOSED (host-bound
|
||||||
|
# selection). The cross-host token must NEVER be sent to the repo host, and no
|
||||||
|
# review POST occurs.
|
||||||
|
if run_review cross-host approve "" https://git.mosaicstack.dev \
|
||||||
|
https://git.mosaicstack.dev/mosaicstack/stack.git mosaicstack/stack "$CROSS_HOST_LOGIN"; then
|
||||||
|
echo "FAIL: cross-host --login override did not fail closed" >&2
|
||||||
|
cat "$OUTPUT_FILE" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if grep -q 'Approved and verified' "$OUTPUT_FILE"; then
|
||||||
|
echo "FAIL: cross-host --login override reported success" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
# The cross-host credential must not have performed ANY request against the repo
|
||||||
|
# host — no request may be attributed to the cross-host identity.
|
||||||
|
if grep -q " $CROSS_HOST_LOGIN\$" "$AUTH_LOG"; then
|
||||||
|
echo "FAIL: cross-host credential was sent to the repo host (cross-host leak)" >&2
|
||||||
|
cat "$AUTH_LOG" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if grep -qE '^POST .*/pulls/123/reviews ' "$AUTH_LOG"; then
|
||||||
|
echo "FAIL: cross-host --login override performed a review POST" >&2
|
||||||
|
cat "$AUTH_LOG" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if grep -q " $ACTING_LOGIN\$" "$AUTH_LOG"; then
|
||||||
|
echo "FAIL: cross-host --login override fell back to the host-default identity" >&2
|
||||||
|
cat "$AUTH_LOG" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
assert_no_temp_leak "cross-host"
|
||||||
|
|
||||||
|
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
|
exit 0
|
||||||
fi
|
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
|
# Call the main QA handler with extracted parameters
|
||||||
if [ -f ~/.config/mosaic/tools/qa/qa-hook-handler.sh ]; then
|
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"
|
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",
|
"lint": "eslint src",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"test": "vitest run --passWithNoTests && pnpm run test:framework-shell",
|
"test": "vitest run --passWithNoTests && pnpm run test:framework-shell",
|
||||||
"test:framework-shell": "python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh"
|
"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": {
|
"dependencies": {
|
||||||
"@mosaicstack/brain": "workspace:*",
|
"@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]:
|
def request(socket_path: Path, value: dict[str, object]) -> dict[str, object]:
|
||||||
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as connection:
|
deadline = time.monotonic() + 5.0
|
||||||
connection.settimeout(3.0)
|
while True:
|
||||||
connection.connect(str(socket_path))
|
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as connection:
|
||||||
connection.sendall((json.dumps(value, separators=(",", ":")) + "\n").encode())
|
connection.settimeout(3.0)
|
||||||
connection.shutdown(socket.SHUT_WR)
|
try:
|
||||||
response = bytearray()
|
connection.connect(str(socket_path))
|
||||||
while True:
|
except ConnectionRefusedError:
|
||||||
chunk = connection.recv(4096)
|
if time.monotonic() >= deadline:
|
||||||
if not chunk:
|
raise
|
||||||
break
|
time.sleep(0.02)
|
||||||
response.extend(chunk)
|
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:
|
if not response.endswith(b"\n") or response.count(b"\n") != 1:
|
||||||
raise AssertionError(f"unframed broker response: {bytes(response)!r}")
|
raise AssertionError(f"unframed broker response: {bytes(response)!r}")
|
||||||
reply = json.loads(response[:-1])
|
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 () => {
|
test('observer revocation and monotonic TTL expiry deny the next mutator', async () => {
|
||||||
const { socket } = await startBroker();
|
const { socket } = await startBroker();
|
||||||
const sessionId = await register(socket);
|
const sessionId = await register(socket);
|
||||||
const pending = await beginVerification(socket, sessionId, 'claude', 1, 1);
|
|
||||||
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({
|
expect(await authorize(socket, sessionId, 'claude', 'Bash')).toMatchObject({
|
||||||
ok: true,
|
ok: true,
|
||||||
decision: 'allow',
|
decision: 'allow',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// A dedicated, isolated short-TTL lease drives the deliberate monotonic
|
||||||
|
// expiry demonstration below. It is never used for anything but the
|
||||||
|
// wait-then-expire assertion, so there is no setup work racing its
|
||||||
|
// 1-second window.
|
||||||
|
const shortLived = await beginVerification(socket, sessionId, 'claude', 1, 1, 2);
|
||||||
|
await promote(socket, sessionId, shortLived.receipt_challenge!);
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1_100));
|
await new Promise((resolve) => setTimeout(resolve, 1_100));
|
||||||
expect(await authorize(socket, sessionId, 'claude', 'Bash')).toMatchObject({
|
expect(await authorize(socket, sessionId, 'claude', 'Bash')).toMatchObject({
|
||||||
ok: false,
|
ok: false,
|
||||||
@@ -675,7 +689,7 @@ describe('whole mutator-class lease gate', () => {
|
|||||||
decision: 'deny',
|
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!);
|
await promote(socket, sessionId, refreshed.receipt_challenge!);
|
||||||
expect(
|
expect(
|
||||||
await request(socket, {
|
await request(socket, {
|
||||||
|
|||||||
@@ -217,12 +217,22 @@ git fetch origin
|
|||||||
mkdir -p ~/src/${projectName}-worktrees
|
mkdir -p ~/src/${projectName}-worktrees
|
||||||
git worktree add ~/src/${projectName}-worktrees/<task-slug> -b <branch-name> origin/main
|
git worktree add ~/src/${projectName}-worktrees/<task-slug> -b <branch-name> origin/main
|
||||||
cd ~/src/${projectName}-worktrees/<task-slug>
|
cd ~/src/${projectName}-worktrees/<task-slug>
|
||||||
|
pnpm install --frozen-lockfile --prefer-offline
|
||||||
# ... all work happens here ...
|
# ... all work happens here ...
|
||||||
git push origin <branch-name>
|
git push origin <branch-name>
|
||||||
cd ~/src/${projectName} && git worktree remove ~/src/${projectName}-worktrees/<task-slug>
|
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
|
// 6. Completion gates
|
||||||
sections.push(`# Completion Gates — ENFORCED
|
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