Compare commits
2 Commits
feat/agent
...
fix/toolin
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5cd6c83b9b | ||
|
|
b0b2c20da0 |
@@ -51,3 +51,48 @@ This repository currently has no root `CHANGELOG.md`; the scratchpad and `docs/T
|
|||||||
- PR #1908: `Dry run: would merge PR #1908 on git.uscllc.com with authenticated Gitea API fallback (base=main, method=squash).`
|
- PR #1908: `Dry run: would merge PR #1908 on git.uscllc.com with authenticated Gitea API fallback (base=main, method=squash).`
|
||||||
- PR: `https://git.mosaicstack.dev/mosaicstack/stack/pulls/518`, branch `fix/t-a292e96f-gitea-pr-metadata`.
|
- PR: `https://git.mosaicstack.dev/mosaicstack/stack/pulls/518`, branch `fix/t-a292e96f-gitea-pr-metadata`.
|
||||||
- CI: Recent PR/push pipelines failed before clone/test execution due Woodpecker/Kubernetes PVC API timeout: `dial tcp 10.43.0.1:443: i/o timeout`. No repository test step executed in CI; local targeted verification above remains clean.
|
- CI: Recent PR/push pipelines failed before clone/test execution due Woodpecker/Kubernetes PVC API timeout: `dial tcp 10.43.0.1:443: i/o timeout`. No repository test step executed in CI; local targeted verification above remains clean.
|
||||||
|
|
||||||
|
## 2026-06-18 — PR #549 functional blocker remediation
|
||||||
|
|
||||||
|
### Assignment
|
||||||
|
|
||||||
|
Coordinator `mos-claude` assigned remediation for PR #549: fix `packages/mosaic/framework/tools/git/pr-metadata.sh` tmpfile cleanup where an `EXIT` trap references function-local `body_file` after the function returns inside `RAW=$(...)`, producing `body_file: unbound variable` on the authenticated success path and failing to clean up safely on early `set -e` exits.
|
||||||
|
|
||||||
|
### Plan
|
||||||
|
|
||||||
|
1. Add a non-vacuous Gitea test that exercises `curl_gitea_pull` with stubbed `curl` and `GITEA_TOKEN` instead of `MOSAIC_GITEA_PR_METADATA_RAW_FILE`.
|
||||||
|
2. Prove the new test is RED against the current PR head.
|
||||||
|
3. Replace the function-local `EXIT` cleanup with robust function-scoped tmpfile cleanup.
|
||||||
|
4. Re-run targeted tests, `bash -n`, and review gates; commit and push branch only. Do not merge.
|
||||||
|
|
||||||
|
### Constraints / assumptions
|
||||||
|
|
||||||
|
- Do not modify prior injection/JSON fixes in `issue-edit`, `issue-assign`, or `milestone-create`.
|
||||||
|
- Worker role: do not modify `docs/TASKS.md`; orchestrator remains the single writer.
|
||||||
|
- Budget: no explicit token cap provided; keep scope to shell wrapper + targeted regression harness.
|
||||||
|
|
||||||
|
### Remediation results
|
||||||
|
|
||||||
|
- Rebased `fix/tooling-eval-injection-jq-json` onto `origin/main`; branch was already current.
|
||||||
|
- Added a curl-stub regression path that does not use `MOSAIC_GITEA_PR_METADATA_RAW_FILE`, so it exercises `curl_gitea_pull` and its temp body file.
|
||||||
|
- RED evidence: copied the new harness next to the pre-fix `HEAD` version of `pr-metadata.sh`; `MOSAIC_TEST_WORK_DIR=$PWD/.mosaic-test-work/pr-metadata-red-work .../test-pr-metadata-gitea.sh` failed with `body_file: unbound variable` on the curl success path.
|
||||||
|
- Fix: replaced `EXIT` temp-file cleanup with a `RETURN`-scoped cleanup function that removes the body file while the function-local variable is still in scope, preserves the original return status, and clears the `RETURN` trap.
|
||||||
|
- GREEN evidence:
|
||||||
|
- `MOSAIC_TEST_WORK_DIR=$PWD/.mosaic-test-work/pr-metadata-gitea-current packages/mosaic/framework/tools/git/test-pr-metadata-gitea.sh` passed.
|
||||||
|
- `bash -n packages/mosaic/framework/tools/git/pr-metadata.sh packages/mosaic/framework/tools/git/test-pr-metadata-gitea.sh` passed.
|
||||||
|
- `shellcheck -x -P . -e SC1090 packages/mosaic/framework/tools/git/pr-metadata.sh packages/mosaic/framework/tools/git/test-pr-metadata-gitea.sh` passed.
|
||||||
|
|
||||||
|
### Review remediation
|
||||||
|
|
||||||
|
- Codex review returned one should-fix: the early-exit test used `chmod 000`, which is not root-safe in container CI.
|
||||||
|
- Remediation: changed the stubbed 2xx/cat-failure mode to replace the curl output with a broken symlink, which fails deterministically even as root and still validates cleanup via `rm -f -- "$body_file"`.
|
||||||
|
|
||||||
|
### Second review remediation
|
||||||
|
|
||||||
|
- Codex review found the 2xx `cat "$body_file"` read could be masked under command substitution semantics because the branch returned 0 unconditionally.
|
||||||
|
- Remediation: both authenticated 2xx branches now use `cat "$body_file" || return $?` before returning success.
|
||||||
|
- Strengthened the broken-symlink test to require the body-read failure and reject the later `Gitea API returned non-JSON` parse-failure path, so the test verifies the helper-level failure propagation rather than eventual downstream failure.
|
||||||
|
|
||||||
|
### Final review gate
|
||||||
|
|
||||||
|
- Codex review after remediation: approved (`0 blockers, 0 should-fix, 0 suggestions`).
|
||||||
|
|||||||
@@ -98,27 +98,32 @@ case "$PLATFORM" in
|
|||||||
;;
|
;;
|
||||||
gitea)
|
gitea)
|
||||||
# tea issue edit syntax
|
# tea issue edit syntax
|
||||||
REPO_ARGS=$(get_gitea_repo_args) || {
|
REPO_SLUG=$(get_repo_slug) || {
|
||||||
echo "Error: Could not resolve Gitea repo/login args for remote host" >&2
|
echo "Error: Could not resolve Gitea repo slug from remote" >&2
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
CMD="tea issue edit $ISSUE $REPO_ARGS"
|
REPO_LOGIN=$(get_gitea_login) || {
|
||||||
|
echo "Error: Could not resolve Gitea login for remote host" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
REPO_ARGS=(--repo "$REPO_SLUG" --login "$REPO_LOGIN")
|
||||||
|
CMD=(tea issue edit "$ISSUE" "${REPO_ARGS[@]}")
|
||||||
NEEDS_EDIT=false
|
NEEDS_EDIT=false
|
||||||
|
|
||||||
if [[ -n "$ASSIGNEE" ]]; then
|
if [[ -n "$ASSIGNEE" ]]; then
|
||||||
# tea uses --assignees flag
|
# tea uses --assignees flag
|
||||||
CMD="$CMD --assignees \"$ASSIGNEE\""
|
CMD+=(--assignees "$ASSIGNEE")
|
||||||
NEEDS_EDIT=true
|
NEEDS_EDIT=true
|
||||||
fi
|
fi
|
||||||
if [[ -n "$LABELS" ]]; then
|
if [[ -n "$LABELS" ]]; then
|
||||||
# tea uses --labels flag (replaces existing)
|
# tea uses --labels flag (replaces existing)
|
||||||
CMD="$CMD --labels \"$LABELS\""
|
CMD+=(--labels "$LABELS")
|
||||||
NEEDS_EDIT=true
|
NEEDS_EDIT=true
|
||||||
fi
|
fi
|
||||||
if [[ -n "$MILESTONE" ]]; then
|
if [[ -n "$MILESTONE" ]]; then
|
||||||
MILESTONE_ID=$(tea milestones list $REPO_ARGS 2>/dev/null | grep -E "^\s*[0-9]+" | grep "$MILESTONE" | awk '{print $1}' | head -1)
|
MILESTONE_ID=$(tea milestones list "${REPO_ARGS[@]}" 2>/dev/null | grep -E "^\s*[0-9]+" | grep "$MILESTONE" | awk '{print $1}' | head -1)
|
||||||
if [[ -n "$MILESTONE_ID" ]]; then
|
if [[ -n "$MILESTONE_ID" ]]; then
|
||||||
CMD="$CMD --milestone $MILESTONE_ID"
|
CMD+=(--milestone "$MILESTONE_ID")
|
||||||
NEEDS_EDIT=true
|
NEEDS_EDIT=true
|
||||||
else
|
else
|
||||||
echo "Warning: Could not find milestone '$MILESTONE'" >&2
|
echo "Warning: Could not find milestone '$MILESTONE'" >&2
|
||||||
@@ -126,7 +131,7 @@ case "$PLATFORM" in
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ "$NEEDS_EDIT" == true ]]; then
|
if [[ "$NEEDS_EDIT" == true ]]; then
|
||||||
eval "$CMD"
|
"${CMD[@]}"
|
||||||
echo "Issue #$ISSUE updated successfully"
|
echo "Issue #$ISSUE updated successfully"
|
||||||
else
|
else
|
||||||
echo "No changes specified"
|
echo "No changes specified"
|
||||||
|
|||||||
@@ -63,24 +63,28 @@ fi
|
|||||||
detect_platform >/dev/null
|
detect_platform >/dev/null
|
||||||
|
|
||||||
if [[ "$PLATFORM" == "github" ]]; then
|
if [[ "$PLATFORM" == "github" ]]; then
|
||||||
CMD="gh issue edit $ISSUE_NUMBER"
|
CMD=(gh issue edit "$ISSUE_NUMBER")
|
||||||
[[ -n "$TITLE" ]] && CMD="$CMD --title \"$TITLE\""
|
[[ -n "$TITLE" ]] && CMD+=(--title "$TITLE")
|
||||||
[[ -n "$BODY" ]] && CMD="$CMD --body \"$BODY\""
|
[[ -n "$BODY" ]] && CMD+=(--body "$BODY")
|
||||||
[[ -n "$LABELS" ]] && CMD="$CMD --add-label \"$LABELS\""
|
[[ -n "$LABELS" ]] && CMD+=(--add-label "$LABELS")
|
||||||
[[ -n "$MILESTONE" ]] && CMD="$CMD --milestone \"$MILESTONE\""
|
[[ -n "$MILESTONE" ]] && CMD+=(--milestone "$MILESTONE")
|
||||||
eval $CMD
|
"${CMD[@]}"
|
||||||
echo "Updated GitHub issue #$ISSUE_NUMBER"
|
echo "Updated GitHub issue #$ISSUE_NUMBER"
|
||||||
elif [[ "$PLATFORM" == "gitea" ]]; then
|
elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||||
REPO_ARGS=$(get_gitea_repo_args) || {
|
REPO_SLUG=$(get_repo_slug) || {
|
||||||
echo "Error: Could not resolve Gitea repo/login args for remote host" >&2
|
echo "Error: Could not resolve Gitea repo slug from remote" >&2
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
CMD="tea issue edit $ISSUE_NUMBER $REPO_ARGS"
|
REPO_LOGIN=$(get_gitea_login) || {
|
||||||
[[ -n "$TITLE" ]] && CMD="$CMD --title \"$TITLE\""
|
echo "Error: Could not resolve Gitea login for remote host" >&2
|
||||||
[[ -n "$BODY" ]] && CMD="$CMD --description \"$BODY\""
|
exit 1
|
||||||
[[ -n "$LABELS" ]] && CMD="$CMD --add-labels \"$LABELS\""
|
}
|
||||||
[[ -n "$MILESTONE" ]] && CMD="$CMD --milestone \"$MILESTONE\""
|
CMD=(tea issue edit "$ISSUE_NUMBER" --repo "$REPO_SLUG" --login "$REPO_LOGIN")
|
||||||
eval $CMD
|
[[ -n "$TITLE" ]] && CMD+=(--title "$TITLE")
|
||||||
|
[[ -n "$BODY" ]] && CMD+=(--description "$BODY")
|
||||||
|
[[ -n "$LABELS" ]] && CMD+=(--add-labels "$LABELS")
|
||||||
|
[[ -n "$MILESTONE" ]] && CMD+=(--milestone "$MILESTONE")
|
||||||
|
"${CMD[@]}"
|
||||||
echo "Updated Gitea issue #$ISSUE_NUMBER"
|
echo "Updated Gitea issue #$ISSUE_NUMBER"
|
||||||
else
|
else
|
||||||
echo "Error: Unknown platform"
|
echo "Error: Unknown platform"
|
||||||
|
|||||||
@@ -99,10 +99,15 @@ fi
|
|||||||
case "$PLATFORM" in
|
case "$PLATFORM" in
|
||||||
github)
|
github)
|
||||||
# GitHub uses the API for milestone creation
|
# GitHub uses the API for milestone creation
|
||||||
JSON_PAYLOAD="{\"title\":\"$TITLE\""
|
# Use jq to safely construct JSON so titles/descriptions containing
|
||||||
[[ -n "$DESCRIPTION" ]] && JSON_PAYLOAD="$JSON_PAYLOAD,\"description\":\"$DESCRIPTION\""
|
# quotes or special characters do not corrupt the payload (F-07).
|
||||||
[[ -n "$DUE_DATE" ]] && JSON_PAYLOAD="$JSON_PAYLOAD,\"due_on\":\"${DUE_DATE}T00:00:00Z\""
|
JSON_PAYLOAD=$(jq -n \
|
||||||
JSON_PAYLOAD="$JSON_PAYLOAD}"
|
--arg t "$TITLE" \
|
||||||
|
--arg d "$DESCRIPTION" \
|
||||||
|
--arg due "${DUE_DATE}" \
|
||||||
|
'{"title": $t}
|
||||||
|
+ (if $d != "" then {"description": $d} else {} end)
|
||||||
|
+ (if $due != "" then {"due_on": ($due + "T00:00:00Z")} else {} end)')
|
||||||
|
|
||||||
gh api repos/:owner/:repo/milestones --method POST --input - <<< "$JSON_PAYLOAD"
|
gh api repos/:owner/:repo/milestones --method POST --input - <<< "$JSON_PAYLOAD"
|
||||||
echo "Milestone '$TITLE' created successfully"
|
echo "Milestone '$TITLE' created successfully"
|
||||||
|
|||||||
@@ -57,12 +57,20 @@ curl_gitea_pull() {
|
|||||||
local token basic_auth raw_code body_file http_code
|
local token basic_auth raw_code body_file http_code
|
||||||
body_file=$(mktemp)
|
body_file=$(mktemp)
|
||||||
|
|
||||||
|
# shellcheck disable=SC2329 # Invoked by the RETURN trap below.
|
||||||
|
cleanup_gitea_pull_body() {
|
||||||
|
local status=$?
|
||||||
|
rm -f -- "$body_file"
|
||||||
|
trap - RETURN
|
||||||
|
return "$status"
|
||||||
|
}
|
||||||
|
trap cleanup_gitea_pull_body RETURN
|
||||||
|
|
||||||
token=$(get_gitea_token "$HOST" || true)
|
token=$(get_gitea_token "$HOST" || true)
|
||||||
if [[ -n "$token" ]]; then
|
if [[ -n "$token" ]]; then
|
||||||
raw_code=$(curl -sS -w '%{http_code}' -o "$body_file" -H "User-Agent: curl/8" -H "Authorization: token $token" "$api_url" || true)
|
raw_code=$(curl -sS -w '%{http_code}' -o "$body_file" -H "User-Agent: curl/8" -H "Authorization: token $token" "$api_url" || true)
|
||||||
if [[ "$raw_code" =~ ^2 ]]; then
|
if [[ "$raw_code" =~ ^2 ]]; then
|
||||||
cat "$body_file"
|
cat "$body_file" || return $?
|
||||||
rm -f "$body_file"
|
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
http_code="$raw_code"
|
http_code="$raw_code"
|
||||||
@@ -72,8 +80,7 @@ curl_gitea_pull() {
|
|||||||
if [[ -n "$basic_auth" ]]; then
|
if [[ -n "$basic_auth" ]]; then
|
||||||
raw_code=$(curl -sS -w '%{http_code}' -o "$body_file" -u "$basic_auth" -H "User-Agent: curl/8" "$api_url" || true)
|
raw_code=$(curl -sS -w '%{http_code}' -o "$body_file" -u "$basic_auth" -H "User-Agent: curl/8" "$api_url" || true)
|
||||||
if [[ "$raw_code" =~ ^2 ]]; then
|
if [[ "$raw_code" =~ ^2 ]]; then
|
||||||
cat "$body_file"
|
cat "$body_file" || return $?
|
||||||
rm -f "$body_file"
|
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
http_code="$raw_code"
|
http_code="$raw_code"
|
||||||
@@ -96,7 +103,6 @@ except Exception:
|
|||||||
message = open(path, encoding="utf-8", errors="replace").read()[:200] or "empty response"
|
message = open(path, encoding="utf-8", errors="replace").read()[:200] or "empty response"
|
||||||
print(f"Error: Gitea pull request API request failed with HTTP {code}: {message}")
|
print(f"Error: Gitea pull request API request failed with HTTP {code}: {message}")
|
||||||
PY
|
PY
|
||||||
rm -f "$body_file"
|
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,9 +7,10 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|||||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-metadata-gitea}"
|
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-metadata-gitea}"
|
||||||
REPO_DIR="$WORK_DIR/repo"
|
REPO_DIR="$WORK_DIR/repo"
|
||||||
FIXTURE_DIR="$WORK_DIR/fixtures"
|
FIXTURE_DIR="$WORK_DIR/fixtures"
|
||||||
|
STUB_DIR="$WORK_DIR/stubs"
|
||||||
|
|
||||||
rm -rf "$WORK_DIR"
|
rm -rf "$WORK_DIR"
|
||||||
mkdir -p "$REPO_DIR" "$FIXTURE_DIR"
|
mkdir -p "$REPO_DIR" "$FIXTURE_DIR" "$STUB_DIR"
|
||||||
|
|
||||||
git -C "$REPO_DIR" init -q
|
git -C "$REPO_DIR" init -q
|
||||||
git -C "$REPO_DIR" remote add origin https://git.uscllc.com/USC/uconnect.git
|
git -C "$REPO_DIR" remote add origin https://git.uscllc.com/USC/uconnect.git
|
||||||
@@ -56,6 +57,150 @@ cat > "$FIXTURE_DIR/gitea-error.json" <<'JSON'
|
|||||||
{"message": "user does not exist [uid: 0, name: ]", "url": "https://git.uscllc.com/api/swagger"}
|
{"message": "user does not exist [uid: 0, name: ]", "url": "https://git.uscllc.com/api/swagger"}
|
||||||
JSON
|
JSON
|
||||||
|
|
||||||
|
cat > "$STUB_DIR/curl" <<'SH'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
output_file=""
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
-o)
|
||||||
|
output_file="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
-w|-H|-u)
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
-s|-S|-sS)
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ -z "$output_file" ]]; then
|
||||||
|
echo "curl stub expected -o <output_file>" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
case "${MOSAIC_STUB_CURL_MODE:-success}" in
|
||||||
|
success)
|
||||||
|
cat > "$output_file" <<'JSON'
|
||||||
|
{
|
||||||
|
"number": 1910,
|
||||||
|
"title": "Live curl path",
|
||||||
|
"state": "open",
|
||||||
|
"user": {"login": "edith"},
|
||||||
|
"head": {"ref": "fix/live-curl-path"},
|
||||||
|
"base": {"ref": "main"},
|
||||||
|
"html_url": "https://git.example.test/acme/widgets/pulls/1910"
|
||||||
|
}
|
||||||
|
JSON
|
||||||
|
printf '200'
|
||||||
|
;;
|
||||||
|
cat-fails-after-2xx)
|
||||||
|
rm -f -- "$output_file"
|
||||||
|
ln -s /nonexistent/pr-metadata-body "$output_file"
|
||||||
|
printf '200'
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "unknown MOSAIC_STUB_CURL_MODE=${MOSAIC_STUB_CURL_MODE:-}" >&2
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
SH
|
||||||
|
chmod +x "$STUB_DIR/curl"
|
||||||
|
|
||||||
|
assert_tmpdir_empty() {
|
||||||
|
local tmpdir="$1" leftover
|
||||||
|
leftover=$(find "$tmpdir" -mindepth 1 -print -quit)
|
||||||
|
if [[ -n "$leftover" ]]; then
|
||||||
|
echo "Expected tmpfile cleanup, found leftover: $leftover" >&2
|
||||||
|
find "$tmpdir" -mindepth 1 -maxdepth 1 -ls >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
run_curl_success_case() {
|
||||||
|
local tmpdir="$WORK_DIR/tmp-success" stderr_file="$WORK_DIR/curl-success.stderr"
|
||||||
|
local output status
|
||||||
|
mkdir -p "$tmpdir"
|
||||||
|
|
||||||
|
set +e
|
||||||
|
output=$(cd "$REPO_DIR" && \
|
||||||
|
PATH="$STUB_DIR:$PATH" \
|
||||||
|
TMPDIR="$tmpdir" \
|
||||||
|
GITEA_TOKEN="stub-token" \
|
||||||
|
GITEA_URL="https://git.example.test" \
|
||||||
|
MOSAIC_STUB_CURL_MODE="success" \
|
||||||
|
"$SCRIPT_DIR/pr-metadata.sh" -n 1910 2>"$stderr_file")
|
||||||
|
status=$?
|
||||||
|
set -e
|
||||||
|
|
||||||
|
if [[ "$status" -ne 0 ]]; then
|
||||||
|
echo "Expected curl success path to pass, got status $status" >&2
|
||||||
|
cat "$stderr_file" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if grep -q "unbound variable" "$stderr_file"; then
|
||||||
|
echo "curl success path emitted unbound-variable cleanup noise" >&2
|
||||||
|
cat "$stderr_file" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
assert_tmpdir_empty "$tmpdir"
|
||||||
|
|
||||||
|
PR_METADATA_OUTPUT="$output" python3 - <<'PY'
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
data = json.loads(os.environ["PR_METADATA_OUTPUT"])
|
||||||
|
assert data["number"] == 1910, data
|
||||||
|
assert data["baseRefName"] == "main", data
|
||||||
|
assert data["headRefName"] == "fix/live-curl-path", data
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
|
run_curl_early_exit_cleanup_case() {
|
||||||
|
local tmpdir="$WORK_DIR/tmp-early-exit" stderr_file="$WORK_DIR/curl-early-exit.stderr"
|
||||||
|
local output status
|
||||||
|
mkdir -p "$tmpdir"
|
||||||
|
|
||||||
|
set +e
|
||||||
|
output=$(cd "$REPO_DIR" && \
|
||||||
|
PATH="$STUB_DIR:$PATH" \
|
||||||
|
TMPDIR="$tmpdir" \
|
||||||
|
GITEA_TOKEN="stub-token" \
|
||||||
|
GITEA_URL="https://git.example.test" \
|
||||||
|
MOSAIC_STUB_CURL_MODE="cat-fails-after-2xx" \
|
||||||
|
"$SCRIPT_DIR/pr-metadata.sh" -n 1910 2>"$stderr_file")
|
||||||
|
status=$?
|
||||||
|
set -e
|
||||||
|
|
||||||
|
if [[ "$status" -eq 0 ]]; then
|
||||||
|
echo "Expected unreadable 2xx body path to fail" >&2
|
||||||
|
printf '%s\n' "$output" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if grep -q "unbound variable" "$stderr_file"; then
|
||||||
|
echo "curl early-exit path emitted unbound-variable cleanup noise" >&2
|
||||||
|
cat "$stderr_file" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if ! grep -q "No such file or directory" "$stderr_file"; then
|
||||||
|
echo "Expected body-read failure from broken symlink path" >&2
|
||||||
|
cat "$stderr_file" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if grep -q "Gitea API returned non-JSON" "$stderr_file"; then
|
||||||
|
echo "curl helper masked body-read failure as later JSON parsing failure" >&2
|
||||||
|
cat "$stderr_file" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
assert_tmpdir_empty "$tmpdir"
|
||||||
|
}
|
||||||
|
|
||||||
run_case() {
|
run_case() {
|
||||||
local fixture="$1" expected_number="$2" expected_head="$3"
|
local fixture="$1" expected_number="$2" expected_head="$3"
|
||||||
local output
|
local output
|
||||||
@@ -77,6 +222,8 @@ PY
|
|||||||
run_case "$FIXTURE_DIR/gitea-standard.json" 1905 edith/t_39ce717c-authentik-smoke-gate
|
run_case "$FIXTURE_DIR/gitea-standard.json" 1905 edith/t_39ce717c-authentik-smoke-gate
|
||||||
run_case "$FIXTURE_DIR/gitea-fallback.json" 1908 fix/fallback-head
|
run_case "$FIXTURE_DIR/gitea-fallback.json" 1908 fix/fallback-head
|
||||||
run_case "$FIXTURE_DIR/gitea-refs-pull-label.json" 1908 fix/t_23fa9e1d-portal-health-backend
|
run_case "$FIXTURE_DIR/gitea-refs-pull-label.json" 1908 fix/t_23fa9e1d-portal-health-backend
|
||||||
|
run_curl_success_case
|
||||||
|
run_curl_early_exit_cleanup_case
|
||||||
|
|
||||||
if cd "$REPO_DIR" && MOSAIC_GITEA_PR_METADATA_RAW_FILE="$FIXTURE_DIR/gitea-error.json" "$SCRIPT_DIR/pr-metadata.sh" -n 1909 >/dev/null 2>"$WORK_DIR/error.log"; then
|
if cd "$REPO_DIR" && MOSAIC_GITEA_PR_METADATA_RAW_FILE="$FIXTURE_DIR/gitea-error.json" "$SCRIPT_DIR/pr-metadata.sh" -n 1909 >/dev/null 2>"$WORK_DIR/error.log"; then
|
||||||
echo "Expected API error fixture to fail" >&2
|
echo "Expected API error fixture to fail" >&2
|
||||||
|
|||||||
@@ -12,10 +12,6 @@
|
|||||||
# ambiguity about lanes or origin. Recipients replying should FLIP the
|
# ambiguity about lanes or origin. Recipients replying should FLIP the
|
||||||
# preamble: [<dst> -> <src>] ... (this tool sends; it does not auto-reply).
|
# preamble: [<dst> -> <src>] ... (this tool sends; it does not auto-reply).
|
||||||
#
|
#
|
||||||
# Optionally tags the message with a TRIAGE CLASS (see -C / --class) so a
|
|
||||||
# comms daemon can route it (deliver-to-agent vs log-and-drop) from an exact
|
|
||||||
# field instead of re-deriving intent from the body.
|
|
||||||
#
|
|
||||||
# WHY A WRAPPER
|
# WHY A WRAPPER
|
||||||
# Reliable submission into an interactive REPL (Claude Code / Codex) is fiddly:
|
# Reliable submission into an interactive REPL (Claude Code / Codex) is fiddly:
|
||||||
# a trailing Enter is often swallowed and the message sits as an unsubmitted
|
# a trailing Enter is often swallowed and the message sits as an unsubmitted
|
||||||
@@ -30,7 +26,6 @@
|
|||||||
# agent-send.sh -s <dst_session> -m "message" # local target
|
# agent-send.sh -s <dst_session> -m "message" # local target
|
||||||
# agent-send.sh -H user@host -s <dst_session> -m "message" # remote target
|
# agent-send.sh -H user@host -s <dst_session> -m "message" # remote target
|
||||||
# agent-send.sh -H user@host -n <dst_hostname> -s <sess> -f msg.txt
|
# agent-send.sh -H user@host -n <dst_hostname> -s <sess> -f msg.txt
|
||||||
# agent-send.sh -s mos-claude --class terminal-log -m "ACK — received"
|
|
||||||
# echo "msg" | agent-send.sh -H user@host -s <dst_session>
|
# echo "msg" | agent-send.sh -H user@host -s <dst_session>
|
||||||
#
|
#
|
||||||
# OPTIONS
|
# OPTIONS
|
||||||
@@ -40,60 +35,26 @@
|
|||||||
# Default: local hostname, or (remote) resolved via one ssh.
|
# Default: local hostname, or (remote) resolved via one ssh.
|
||||||
# -m MESSAGE message text (single- or multi-line)
|
# -m MESSAGE message text (single- or multi-line)
|
||||||
# -f FILE read message from FILE instead of -m
|
# -f FILE read message from FILE instead of -m
|
||||||
# -C CLASS triage class for a comms daemon. One of:
|
|
||||||
# terminal-log log-only; never needs the agent's attention
|
|
||||||
# actionable carries a decision/blocker/gate — deliver
|
|
||||||
# human from a human operator — deliver
|
|
||||||
# reaction an emoji/ack reaction
|
|
||||||
# Long form: --class CLASS (or --class=CLASS). When SET, the
|
|
||||||
# preamble carries a ` class=<CLASS>` token INSIDE the bracket:
|
|
||||||
# [<src> -> <dst> class=terminal-log] <message>
|
|
||||||
# When OMITTED, NO token is emitted and the preamble is
|
|
||||||
# byte-for-byte identical to the classic format. Consumers MUST
|
|
||||||
# treat an absent class as 'actionable' (fail-safe: agent sees it).
|
|
||||||
# -S SRC_LABEL override source label "<host>:<session>" (default: auto)
|
# -S SRC_LABEL override source label "<host>:<session>" (default: auto)
|
||||||
# -r N Enter-flush attempts passed through (default 2)
|
# -r N Enter-flush attempts passed through (default 2)
|
||||||
# -v verbose: print pane tail after delivery
|
# -v verbose: print pane tail after delivery
|
||||||
# -h help
|
# -h help
|
||||||
#
|
#
|
||||||
# PREAMBLE GRAMMAR (for consumers / daemons mirroring this producer)
|
|
||||||
# ^\[(\S+) -> (\S+?)(?: class=(terminal-log|actionable|human|reaction))?\] (.*)$
|
|
||||||
# group 1 = src label group 2 = dst host:session
|
|
||||||
# group 3 = class (absent => actionable) group 4 = message body
|
|
||||||
#
|
|
||||||
# EXIT CODES (passed through from send-message.sh)
|
# EXIT CODES (passed through from send-message.sh)
|
||||||
# 0 delivered/queued · 1 target not found · 2 still draft · 3 usage error
|
# 0 delivered/queued · 1 target not found · 2 still draft · 3 usage error
|
||||||
set -uo pipefail
|
set -uo pipefail
|
||||||
|
|
||||||
SELF_DIR=$(cd -- "$(dirname -- "$0")" && pwd)
|
SELF_DIR=$(cd -- "$(dirname -- "$0")" && pwd)
|
||||||
# Sender is overridable via env purely for testing (inject a capture stub). The
|
SENDER="$SELF_DIR/send-message.sh"
|
||||||
# default is the canonical send-message.sh beside this script; production callers
|
|
||||||
# never set AGENT_SEND_SENDER, so behavior is unchanged.
|
|
||||||
SENDER="${AGENT_SEND_SENDER:-$SELF_DIR/send-message.sh}"
|
|
||||||
|
|
||||||
# Translate the long option --class[=value] into "-C value" so getopts (which is
|
|
||||||
# short-option-only) can parse it. Every other argument passes through untouched,
|
|
||||||
# so callers that never use --class hit the exact original getopts path.
|
|
||||||
args=()
|
|
||||||
while [ $# -gt 0 ]; do
|
|
||||||
case "$1" in
|
|
||||||
--class) [ $# -ge 2 ] || { echo "ERROR: --class requires a value" >&2; exit 3; }
|
|
||||||
args+=(-C "$2"); shift 2 ;;
|
|
||||||
--class=*) args+=(-C "${1#*=}"); shift ;;
|
|
||||||
*) args+=("$1"); shift ;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
set -- ${args[@]+"${args[@]}"}
|
|
||||||
|
|
||||||
DST_SESSION=""; SSH_TARGET=""; DST_HOST=""; MSG=""; FILE=""
|
DST_SESSION=""; SSH_TARGET=""; DST_HOST=""; MSG=""; FILE=""
|
||||||
SRC_LABEL=""; RETRIES=2; VERBOSE=0; CLASS=""
|
SRC_LABEL=""; RETRIES=2; VERBOSE=0
|
||||||
usage() { sed -n '2,/^set -uo pipefail/{/^set -uo pipefail/d;p}' "$0"; exit "${1:-3}"; }
|
usage() { sed -n '2,44p' "$0"; exit "${1:-3}"; }
|
||||||
|
|
||||||
while getopts "s:H:n:m:f:S:r:C:vh" o; do
|
while getopts "s:H:n:m:f:S:r:vh" o; do
|
||||||
case "$o" in
|
case "$o" in
|
||||||
s) DST_SESSION=$OPTARG ;; H) SSH_TARGET=$OPTARG ;; n) DST_HOST=$OPTARG ;;
|
s) DST_SESSION=$OPTARG ;; H) SSH_TARGET=$OPTARG ;; n) DST_HOST=$OPTARG ;;
|
||||||
m) MSG=$OPTARG ;; f) FILE=$OPTARG ;; S) SRC_LABEL=$OPTARG ;;
|
m) MSG=$OPTARG ;; f) FILE=$OPTARG ;; S) SRC_LABEL=$OPTARG ;;
|
||||||
C) CLASS=$OPTARG ;;
|
|
||||||
r) RETRIES=$OPTARG ;; v) VERBOSE=1 ;; h) usage 0 ;; *) usage 3 ;;
|
r) RETRIES=$OPTARG ;; v) VERBOSE=1 ;; h) usage 0 ;; *) usage 3 ;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
@@ -101,17 +62,6 @@ done
|
|||||||
[ -n "$DST_SESSION" ] || { echo "ERROR: -s DST_SESSION is required" >&2; usage 3; }
|
[ -n "$DST_SESSION" ] || { echo "ERROR: -s DST_SESSION is required" >&2; usage 3; }
|
||||||
[ -x "$SENDER" ] || { echo "ERROR: send-message.sh not found beside this script" >&2; exit 3; }
|
[ -x "$SENDER" ] || { echo "ERROR: send-message.sh not found beside this script" >&2; exit 3; }
|
||||||
|
|
||||||
# Validate the triage class only when one was given. An absent class emits NO
|
|
||||||
# token (preamble byte-identical to the classic format); the consumer defaults
|
|
||||||
# absent => actionable.
|
|
||||||
CLASS_TOKEN=""
|
|
||||||
if [ -n "$CLASS" ]; then
|
|
||||||
case "$CLASS" in
|
|
||||||
terminal-log|actionable|human|reaction) CLASS_TOKEN=" class=${CLASS}" ;;
|
|
||||||
*) echo "ERROR: invalid --class '$CLASS' (allowed: terminal-log, actionable, human, reaction)" >&2; exit 3 ;;
|
|
||||||
esac
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Message body from -f / -m / stdin.
|
# Message body from -f / -m / stdin.
|
||||||
if [ -n "$FILE" ]; then [ -r "$FILE" ] || { echo "ERROR: cannot read $FILE" >&2; exit 3; }; MSG=$(cat -- "$FILE")
|
if [ -n "$FILE" ]; then [ -r "$FILE" ] || { echo "ERROR: cannot read $FILE" >&2; exit 3; }; MSG=$(cat -- "$FILE")
|
||||||
elif [ -z "$MSG" ] && [ ! -t 0 ]; then MSG=$(cat)
|
elif [ -z "$MSG" ] && [ ! -t 0 ]; then MSG=$(cat)
|
||||||
@@ -134,7 +84,7 @@ if [ -z "$DST_HOST" ]; then
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
PREAMBLE="[${SRC_LABEL} -> ${DST_HOST}:${DST_SESSION}${CLASS_TOKEN}]"
|
PREAMBLE="[${SRC_LABEL} -> ${DST_HOST}:${DST_SESSION}]"
|
||||||
FULL="${PREAMBLE} ${MSG}"
|
FULL="${PREAMBLE} ${MSG}"
|
||||||
B64=$(printf '%s' "$FULL" | base64 -w0)
|
B64=$(printf '%s' "$FULL" | base64 -w0)
|
||||||
|
|
||||||
|
|||||||
@@ -1,97 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# agent-send.test.sh — regression + grammar lock for agent-send.sh --class.
|
|
||||||
#
|
|
||||||
# Strategy: inject a capture stub via AGENT_SEND_SENDER that decodes the -b
|
|
||||||
# base64 payload and prints the FULL message (preamble + body) so we can assert
|
|
||||||
# the exact bytes on the wire. Local path only (no ssh), -n pins the dst host so
|
|
||||||
# the preamble is deterministic across machines.
|
|
||||||
#
|
|
||||||
# Guarantees locked here:
|
|
||||||
# 1. REGRESSION BAR — no --class => preamble byte-for-byte identical to classic.
|
|
||||||
# 2. --class <c> => ` class=<c>` token emitted inside the bracket.
|
|
||||||
# 3. --class=<c> (equals form) parses identically to the space form.
|
|
||||||
# 4. -C <c> short form parses identically.
|
|
||||||
# 5. invalid class => exit 3, nothing sent.
|
|
||||||
# 6. --class with no value => exit 3.
|
|
||||||
# 7. the documented consumer regex parses producer output for every class.
|
|
||||||
set -uo pipefail
|
|
||||||
|
|
||||||
HERE=$(cd -- "$(dirname -- "$0")" && pwd)
|
|
||||||
TOOL="$HERE/agent-send.sh"
|
|
||||||
|
|
||||||
# Capture stub: stands in for send-message.sh. Decodes -b and prints the payload.
|
|
||||||
STUB=$(mktemp)
|
|
||||||
trap 'rm -f "$STUB"' EXIT
|
|
||||||
cat >"$STUB" <<'STUB_EOF'
|
|
||||||
#!/usr/bin/env bash
|
|
||||||
set -uo pipefail
|
|
||||||
b64=""
|
|
||||||
while getopts "t:b:r:v" o; do case "$o" in b) b64=$OPTARG ;; *) : ;; esac; done
|
|
||||||
printf '%s' "$b64" | base64 -d
|
|
||||||
STUB_EOF
|
|
||||||
chmod +x "$STUB"
|
|
||||||
|
|
||||||
PASS=0; FAIL=0
|
|
||||||
ok() { PASS=$((PASS+1)); printf 'ok %s\n' "$1"; }
|
|
||||||
no() { FAIL=$((FAIL+1)); printf 'FAIL %s\n %s\n' "$1" "$2"; }
|
|
||||||
|
|
||||||
# Run the tool with the stub injected; echoes captured payload on stdout.
|
|
||||||
run() { AGENT_SEND_SENDER="$STUB" bash "$TOOL" -S a:src -n dsthost "$@"; }
|
|
||||||
|
|
||||||
# Documented consumer grammar — the daemon will mirror exactly this.
|
|
||||||
GRAMMAR='^\[(\S+) -> (\S+) class=(terminal-log|actionable|human|reaction)\] (.*)$'
|
|
||||||
GRAMMAR_NOCLASS='^\[(\S+) -> (\S+)\] (.*)$'
|
|
||||||
|
|
||||||
# 1. REGRESSION BAR: classic preamble, byte-for-byte.
|
|
||||||
got=$(run -s mos -m "hello world")
|
|
||||||
want='[a:src -> dsthost:mos] hello world'
|
|
||||||
[ "$got" = "$want" ] && ok "regression: no --class is byte-identical" \
|
|
||||||
|| no "regression: no --class is byte-identical" "got=[$got] want=[$want]"
|
|
||||||
|
|
||||||
# 2. --class space form emits the token.
|
|
||||||
got=$(run -s mos --class terminal-log -m "ACK")
|
|
||||||
want='[a:src -> dsthost:mos class=terminal-log] ACK'
|
|
||||||
[ "$got" = "$want" ] && ok "--class terminal-log emits token" \
|
|
||||||
|| no "--class terminal-log emits token" "got=[$got] want=[$want]"
|
|
||||||
|
|
||||||
# 3. --class=value equals form.
|
|
||||||
got=$(run -s mos --class=actionable -m "decide X")
|
|
||||||
want='[a:src -> dsthost:mos class=actionable] decide X'
|
|
||||||
[ "$got" = "$want" ] && ok "--class=actionable (equals form)" \
|
|
||||||
|| no "--class=actionable (equals form)" "got=[$got] want=[$want]"
|
|
||||||
|
|
||||||
# 4. -C short form.
|
|
||||||
got=$(run -s mos -C human -m "from a person")
|
|
||||||
want='[a:src -> dsthost:mos class=human] from a person'
|
|
||||||
[ "$got" = "$want" ] && ok "-C human (short form)" \
|
|
||||||
|| no "-C human (short form)" "got=[$got] want=[$want]"
|
|
||||||
|
|
||||||
# 5. invalid class => exit 3, no send.
|
|
||||||
if out=$(run -s mos --class bogus -m "x" 2>/dev/null); then
|
|
||||||
no "invalid class rejected" "expected non-zero exit, got 0 (out=[$out])"
|
|
||||||
else
|
|
||||||
rc=$?
|
|
||||||
[ "$rc" = 3 ] && [ -z "$out" ] && ok "invalid class => exit 3, nothing sent" \
|
|
||||||
|| no "invalid class => exit 3, nothing sent" "rc=$rc out=[$out]"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 6. --class with no value => exit 3.
|
|
||||||
if run -s mos -m "x" --class 2>/dev/null; then
|
|
||||||
no "--class with no value rejected" "expected non-zero exit, got 0"
|
|
||||||
else
|
|
||||||
[ "$?" = 3 ] && ok "--class with no value => exit 3" || no "--class with no value => exit 3" "wrong rc"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 7. consumer grammar parses every class + classic line.
|
|
||||||
for c in terminal-log actionable human reaction; do
|
|
||||||
line=$(run -s mos --class "$c" -m "body $c")
|
|
||||||
[[ "$line" =~ $GRAMMAR ]] && [ "${BASH_REMATCH[3]}" = "$c" ] && [ "${BASH_REMATCH[4]}" = "body $c" ] \
|
|
||||||
&& ok "grammar parses class=$c" || no "grammar parses class=$c" "line=[$line]"
|
|
||||||
done
|
|
||||||
classic=$(run -s mos -m "plain body")
|
|
||||||
[[ "$classic" =~ $GRAMMAR_NOCLASS ]] && [ "${BASH_REMATCH[3]}" = "plain body" ] \
|
|
||||||
&& ok "grammar (no-class) parses classic line" || no "grammar (no-class) parses classic line" "line=[$classic]"
|
|
||||||
|
|
||||||
echo "---"
|
|
||||||
echo "PASS=$PASS FAIL=$FAIL"
|
|
||||||
[ "$FAIL" -eq 0 ]
|
|
||||||
Reference in New Issue
Block a user