p0 line landing: coord board/roll + wrapper usage contract + dispatch layer (successor to #1467) (#1468)
ci/woodpecker/push/publish Pipeline was canceled
ci/woodpecker/push/publish Pipeline was canceled
Co-authored-by: orch-01 <[email protected]>
This commit was merged in pull request #1468.
This commit is contained in:
@@ -3,6 +3,7 @@
|
|||||||
# Usage: issue-assign.sh -i ISSUE_NUMBER [-a assignee] [-l labels] [-m milestone]
|
# Usage: issue-assign.sh -i ISSUE_NUMBER [-a assignee] [-l labels] [-m milestone]
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
set -o pipefail
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
source "$SCRIPT_DIR/detect-platform.sh"
|
source "$SCRIPT_DIR/detect-platform.sh"
|
||||||
@@ -33,25 +34,36 @@ Examples:
|
|||||||
$(basename "$0") -i 42 -l "in-progress" -m "0.2.0"
|
$(basename "$0") -i 42 -l "in-progress" -m "0.2.0"
|
||||||
$(basename "$0") -i 42 -a @me
|
$(basename "$0") -i 42 -a @me
|
||||||
EOF
|
EOF
|
||||||
exit "${1:-1}"
|
exit "${1:-2}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Usage-error contract (R4, 2026-08-28): usage errors print to STDERR and exit 2,
|
||||||
|
# distinct from provider, credential, and verification failures (exit 1).
|
||||||
|
usage_error() {
|
||||||
|
echo "Error: $*" >&2
|
||||||
|
usage >&2
|
||||||
}
|
}
|
||||||
|
|
||||||
# Parse arguments
|
# Parse arguments
|
||||||
while [[ $# -gt 0 ]]; do
|
while [[ $# -gt 0 ]]; do
|
||||||
case $1 in
|
case $1 in
|
||||||
-i|--issue)
|
-i|--issue)
|
||||||
|
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||||
ISSUE="$2"
|
ISSUE="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
-a|--assignee)
|
-a|--assignee)
|
||||||
|
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||||
ASSIGNEE="$2"
|
ASSIGNEE="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
-l|--labels)
|
-l|--labels)
|
||||||
|
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||||
LABELS="$2"
|
LABELS="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
-m|--milestone)
|
-m|--milestone)
|
||||||
|
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||||
MILESTONE="$2"
|
MILESTONE="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
@@ -79,20 +91,35 @@ PLATFORM=$(detect_platform)
|
|||||||
case "$PLATFORM" in
|
case "$PLATFORM" in
|
||||||
github)
|
github)
|
||||||
if [[ -n "$ASSIGNEE" ]]; then
|
if [[ -n "$ASSIGNEE" ]]; then
|
||||||
gh issue edit "$ISSUE" --add-assignee "$ASSIGNEE"
|
prov_rc=0
|
||||||
|
gh issue edit "$ISSUE" --add-assignee "$ASSIGNEE" || prov_rc=$?
|
||||||
|
[[ "$prov_rc" -eq 0 ]] || { echo "Error: provider command failed (exit ${prov_rc}; provider failure, not a usage error)" >&2; exit 1; }
|
||||||
fi
|
fi
|
||||||
if [[ "$REMOVE_ASSIGNEE" == true ]]; then
|
if [[ "$REMOVE_ASSIGNEE" == true ]]; then
|
||||||
# Get current assignees and remove them
|
# Get current assignees and remove them
|
||||||
CURRENT=$(gh issue view "$ISSUE" --json assignees -q '.assignees[].login' 2>/dev/null | tr '\n' ',')
|
# pipefail preserves the provider status through the pipeline;
|
||||||
|
# a FAILED lookup exits here instead of reading as a silent
|
||||||
|
# no-assignees skip (codex PR #1464). A successful lookup with
|
||||||
|
# zero assignees still skips the edit below.
|
||||||
|
CURRENT=$(gh issue view "$ISSUE" --json assignees -q '.assignees[].login' 2>/dev/null | tr '\n' ',') || {
|
||||||
|
echo "Error: could not read current assignees (provider lookup failed)" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
if [[ -n "$CURRENT" ]]; then
|
if [[ -n "$CURRENT" ]]; then
|
||||||
gh issue edit "$ISSUE" --remove-assignee "${CURRENT%,}"
|
prov_rc=0
|
||||||
|
gh issue edit "$ISSUE" --remove-assignee "${CURRENT%,}" || prov_rc=$?
|
||||||
|
[[ "$prov_rc" -eq 0 ]] || { echo "Error: provider command failed (exit ${prov_rc}; provider failure, not a usage error)" >&2; exit 1; }
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
if [[ -n "$LABELS" ]]; then
|
if [[ -n "$LABELS" ]]; then
|
||||||
gh issue edit "$ISSUE" --add-label "$LABELS"
|
prov_rc=0
|
||||||
|
gh issue edit "$ISSUE" --add-label "$LABELS" || prov_rc=$?
|
||||||
|
[[ "$prov_rc" -eq 0 ]] || { echo "Error: provider command failed (exit ${prov_rc}; provider failure, not a usage error)" >&2; exit 1; }
|
||||||
fi
|
fi
|
||||||
if [[ -n "$MILESTONE" ]]; then
|
if [[ -n "$MILESTONE" ]]; then
|
||||||
gh issue edit "$ISSUE" --milestone "$MILESTONE"
|
prov_rc=0
|
||||||
|
gh issue edit "$ISSUE" --milestone "$MILESTONE" || prov_rc=$?
|
||||||
|
[[ "$prov_rc" -eq 0 ]] || { echo "Error: provider command failed (exit ${prov_rc}; provider failure, not a usage error)" >&2; exit 1; }
|
||||||
fi
|
fi
|
||||||
echo "Issue #$ISSUE updated successfully"
|
echo "Issue #$ISSUE updated successfully"
|
||||||
;;
|
;;
|
||||||
@@ -131,7 +158,9 @@ case "$PLATFORM" in
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ "$NEEDS_EDIT" == true ]]; then
|
if [[ "$NEEDS_EDIT" == true ]]; then
|
||||||
"${CMD[@]}"
|
prov_rc=0
|
||||||
|
"${CMD[@]}" || prov_rc=$?
|
||||||
|
[[ "$prov_rc" -eq 0 ]] || { echo "Error: provider command failed (exit ${prov_rc}; provider failure, not a usage error)" >&2; exit 1; }
|
||||||
echo "Issue #$ISSUE updated successfully"
|
echo "Issue #$ISSUE updated successfully"
|
||||||
else
|
else
|
||||||
echo "No changes specified"
|
echo "No changes specified"
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# issue-close.sh - Close an issue on GitHub or Gitea
|
# issue-close.sh - Close an issue on GitHub or Gitea
|
||||||
# Usage: issue-close.sh -i <issue_number> [-c <comment>]
|
# Usage: issue-close.sh -i <issue_number> [-b <comment>]
|
||||||
|
# (-c/--comment is a backward-compatible alias for -b/--body; R1/R4 2026-08-28)
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
@@ -12,35 +13,49 @@ source "$SCRIPT_DIR/detect-platform.sh"
|
|||||||
ISSUE_NUMBER=""
|
ISSUE_NUMBER=""
|
||||||
COMMENT=""
|
COMMENT=""
|
||||||
|
|
||||||
|
# Usage-error contract (R4, 2026-08-28): usage errors print to STDERR and exit 2,
|
||||||
|
# distinct from provider, credential, and verification failures (exit 1), so a
|
||||||
|
# caller or stop gate can tell an invocation defect from a delivery blocker.
|
||||||
|
usage_error() {
|
||||||
|
echo "Error: $*" >&2
|
||||||
|
echo "Usage: issue-close.sh -i <issue_number> [-b <comment>] (see --help)" >&2
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
|
||||||
while [[ $# -gt 0 ]]; do
|
while [[ $# -gt 0 ]]; do
|
||||||
case $1 in
|
case $1 in
|
||||||
-i|--issue)
|
-i|--issue)
|
||||||
|
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||||
ISSUE_NUMBER="$2"
|
ISSUE_NUMBER="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
-c|--comment)
|
-b|--body|-c|--comment)
|
||||||
|
# R1 (2026-08-28): --body is the canonical flag; -c/--comment stays
|
||||||
|
# a backward-compatible alias.
|
||||||
|
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||||
COMMENT="$2"
|
COMMENT="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
-h|--help)
|
-h|--help)
|
||||||
echo "Usage: issue-close.sh -i <issue_number> [-c <comment>]"
|
echo "Usage: issue-close.sh -i <issue_number> [-b <comment>]"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Options:"
|
echo "Options:"
|
||||||
echo " -i, --issue Issue number (required)"
|
echo " -i, --issue Issue number (required)"
|
||||||
echo " -c, --comment Comment to add before closing (optional)"
|
echo " -b, --body Comment to add before closing (optional; canonical)"
|
||||||
|
echo " -c, --comment Alias for --body"
|
||||||
echo " -h, --help Show this help"
|
echo " -h, --help Show this help"
|
||||||
|
echo ""
|
||||||
|
echo "Exit codes: 0 success; 2 usage error (stderr); 1 provider/credential/verification failure."
|
||||||
exit 0
|
exit 0
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
echo "Unknown option: $1"
|
usage_error "unknown option: $1"
|
||||||
exit 1
|
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
if [[ -z "$ISSUE_NUMBER" ]]; then
|
if [[ -z "$ISSUE_NUMBER" ]]; then
|
||||||
echo "Error: Issue number is required (-i)"
|
usage_error "issue number is required (-i/--issue)"
|
||||||
exit 1
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Detect platform and close issue
|
# Detect platform and close issue
|
||||||
@@ -82,10 +97,22 @@ gitea_issue_close_api() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if [[ "$PLATFORM" == "github" ]]; then
|
if [[ "$PLATFORM" == "github" ]]; then
|
||||||
|
# R4: normalize provider failures to exit 1 (gh's own usage errors exit 2
|
||||||
|
# and would collide with the reserved usage-error status).
|
||||||
if [[ -n "$COMMENT" ]]; then
|
if [[ -n "$COMMENT" ]]; then
|
||||||
gh issue comment "$ISSUE_NUMBER" --body "$COMMENT"
|
gh_rc=0
|
||||||
|
gh issue comment "$ISSUE_NUMBER" --body "$COMMENT" || gh_rc=$?
|
||||||
|
if [[ "$gh_rc" -ne 0 ]]; then
|
||||||
|
echo "Error: GitHub comment before close failed (gh exit $gh_rc)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
gh_rc=0
|
||||||
|
gh issue close "$ISSUE_NUMBER" || gh_rc=$?
|
||||||
|
if [[ "$gh_rc" -ne 0 ]]; then
|
||||||
|
echo "Error: GitHub issue close failed (gh exit $gh_rc)" >&2
|
||||||
|
exit 1
|
||||||
fi
|
fi
|
||||||
gh issue close "$ISSUE_NUMBER"
|
|
||||||
echo "Closed GitHub issue #$ISSUE_NUMBER"
|
echo "Closed GitHub issue #$ISSUE_NUMBER"
|
||||||
elif [[ "$PLATFORM" == "gitea" ]]; then
|
elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||||
GITEA_LOGIN_NAME=$(get_gitea_login || true)
|
GITEA_LOGIN_NAME=$(get_gitea_login || true)
|
||||||
@@ -107,7 +134,9 @@ elif [[ "$PLATFORM" == "gitea" ]]; then
|
|||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
fi
|
fi
|
||||||
tea issue close "$ISSUE_NUMBER" --repo "$OWNER/$REPO" --login "$GITEA_LOGIN_NAME"
|
prov_rc=0
|
||||||
|
tea issue close "$ISSUE_NUMBER" --repo "$OWNER/$REPO" --login "$GITEA_LOGIN_NAME" || prov_rc=$?
|
||||||
|
[[ "$prov_rc" -eq 0 ]] || { echo "Error: provider command failed (exit ${prov_rc}; provider failure, not a usage error)" >&2; exit 1; }
|
||||||
else
|
else
|
||||||
echo "No tea login configured for $(get_remote_host); using authenticated Gitea API fallback." >&2
|
echo "No tea login configured for $(get_remote_host); using authenticated Gitea API fallback." >&2
|
||||||
if [[ -n "$COMMENT" ]]; then
|
if [[ -n "$COMMENT" ]]; then
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ usage_error() {
|
|||||||
while [[ $# -gt 0 ]]; do
|
while [[ $# -gt 0 ]]; do
|
||||||
case $1 in
|
case $1 in
|
||||||
-i|--issue)
|
-i|--issue)
|
||||||
[[ $# -ge 2 ]] || usage_error "option $1 requires a value"
|
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||||
ISSUE_NUMBER="$2"
|
ISSUE_NUMBER="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
@@ -54,12 +54,12 @@ while [[ $# -gt 0 ]]; do
|
|||||||
# R1 (2026-08-28): --body is the canonical flag, matching
|
# R1 (2026-08-28): --body is the canonical flag, matching
|
||||||
# issue-create/issue-edit/pr-create/pr-edit; -c/--comment stays a
|
# issue-create/issue-edit/pr-create/pr-edit; -c/--comment stays a
|
||||||
# backward-compatible alias.
|
# backward-compatible alias.
|
||||||
[[ $# -ge 2 ]] || usage_error "option $1 requires a value"
|
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||||
COMMENT="$2"
|
COMMENT="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
-l|--login)
|
-l|--login)
|
||||||
[[ $# -ge 2 ]] || usage_error "option $1 requires a value"
|
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||||
LOGIN_OVERRIDE="$2"
|
LOGIN_OVERRIDE="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
|
|||||||
@@ -74,26 +74,39 @@ Examples:
|
|||||||
$(basename "$0") -t "Fix login bug" -l "bug,priority-high"
|
$(basename "$0") -t "Fix login bug" -l "bug,priority-high"
|
||||||
$(basename "$0") -t "Add dark mode" -b "Implement theme switching" -m "0.2.0"
|
$(basename "$0") -t "Add dark mode" -b "Implement theme switching" -m "0.2.0"
|
||||||
$(basename "$0") -i
|
$(basename "$0") -i
|
||||||
|
|
||||||
|
Exit codes: 0 success; 2 usage error (stderr); 1 provider/credential failure.
|
||||||
EOF
|
EOF
|
||||||
exit "${1:-1}"
|
exit "${1:-2}"
|
||||||
}
|
}
|
||||||
|
|
||||||
# Parse arguments
|
# Parse arguments
|
||||||
|
# Usage-error contract (R4, 2026-08-28): usage errors print to STDERR and exit 2,
|
||||||
|
# distinct from provider, credential, and verification failures (exit 1).
|
||||||
|
usage_error() {
|
||||||
|
echo "Error: $*" >&2
|
||||||
|
usage >&2
|
||||||
|
}
|
||||||
|
|
||||||
while [[ $# -gt 0 ]]; do
|
while [[ $# -gt 0 ]]; do
|
||||||
case $1 in
|
case $1 in
|
||||||
-t|--title)
|
-t|--title)
|
||||||
|
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||||
TITLE="$2"
|
TITLE="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
-b|--body)
|
-b|--body)
|
||||||
|
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||||
BODY="$2"
|
BODY="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
-l|--labels)
|
-l|--labels)
|
||||||
|
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||||
LABELS="$2"
|
LABELS="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
-m|--milestone)
|
-m|--milestone)
|
||||||
|
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||||
MILESTONE="$2"
|
MILESTONE="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
@@ -131,7 +144,9 @@ case "$PLATFORM" in
|
|||||||
[[ -n "$BODY" ]] && CMD+=(--body "$BODY")
|
[[ -n "$BODY" ]] && CMD+=(--body "$BODY")
|
||||||
[[ -n "$LABELS" ]] && CMD+=(--label "$LABELS")
|
[[ -n "$LABELS" ]] && CMD+=(--label "$LABELS")
|
||||||
[[ -n "$MILESTONE" ]] && CMD+=(--milestone "$MILESTONE")
|
[[ -n "$MILESTONE" ]] && CMD+=(--milestone "$MILESTONE")
|
||||||
"${CMD[@]}"
|
prov_rc=0
|
||||||
|
"${CMD[@]}" || prov_rc=$?
|
||||||
|
[[ "$prov_rc" -eq 0 ]] || { echo "Error: provider command failed (exit ${prov_rc}; provider failure, not a usage error)" >&2; exit 1; }
|
||||||
;;
|
;;
|
||||||
gitea)
|
gitea)
|
||||||
if command -v tea >/dev/null 2>&1; then
|
if command -v tea >/dev/null 2>&1; then
|
||||||
|
|||||||
@@ -14,25 +14,39 @@ BODY=""
|
|||||||
LABELS=""
|
LABELS=""
|
||||||
MILESTONE=""
|
MILESTONE=""
|
||||||
|
|
||||||
|
# Usage-error contract (R4, 2026-08-28): usage errors print to STDERR and exit 2,
|
||||||
|
# distinct from provider, credential, and verification failures (exit 1), so a
|
||||||
|
# caller or stop gate can tell an invocation defect from a delivery blocker.
|
||||||
|
usage_error() {
|
||||||
|
echo "Error: $*" >&2
|
||||||
|
echo "Usage: issue-edit.sh -i <issue_number> [-t <title>] [-b <body>] [-l <labels>] [-m <milestone>] (see --help)" >&2
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
|
||||||
while [[ $# -gt 0 ]]; do
|
while [[ $# -gt 0 ]]; do
|
||||||
case $1 in
|
case $1 in
|
||||||
-i|--issue)
|
-i|--issue)
|
||||||
|
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||||
ISSUE_NUMBER="$2"
|
ISSUE_NUMBER="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
-t|--title)
|
-t|--title)
|
||||||
|
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||||
TITLE="$2"
|
TITLE="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
-b|--body)
|
-b|--body)
|
||||||
|
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||||
BODY="$2"
|
BODY="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
-l|--labels)
|
-l|--labels)
|
||||||
|
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||||
LABELS="$2"
|
LABELS="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
-m|--milestone)
|
-m|--milestone)
|
||||||
|
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||||
MILESTONE="$2"
|
MILESTONE="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
@@ -46,18 +60,18 @@ while [[ $# -gt 0 ]]; do
|
|||||||
echo " -l, --labels Labels (comma-separated, replaces existing)"
|
echo " -l, --labels Labels (comma-separated, replaces existing)"
|
||||||
echo " -m, --milestone Milestone name"
|
echo " -m, --milestone Milestone name"
|
||||||
echo " -h, --help Show this help"
|
echo " -h, --help Show this help"
|
||||||
|
echo ""
|
||||||
|
echo "Exit codes: 0 success; 2 usage error (stderr); 1 provider/credential/verification failure."
|
||||||
exit 0
|
exit 0
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
echo "Unknown option: $1"
|
usage_error "unknown option: $1"
|
||||||
exit 1
|
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
if [[ -z "$ISSUE_NUMBER" ]]; then
|
if [[ -z "$ISSUE_NUMBER" ]]; then
|
||||||
echo "Error: Issue number is required (-i)"
|
usage_error "issue number is required (-i/--issue)"
|
||||||
exit 1
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
detect_platform >/dev/null
|
detect_platform >/dev/null
|
||||||
@@ -68,7 +82,9 @@ if [[ "$PLATFORM" == "github" ]]; then
|
|||||||
[[ -n "$BODY" ]] && CMD+=(--body "$BODY")
|
[[ -n "$BODY" ]] && CMD+=(--body "$BODY")
|
||||||
[[ -n "$LABELS" ]] && CMD+=(--add-label "$LABELS")
|
[[ -n "$LABELS" ]] && CMD+=(--add-label "$LABELS")
|
||||||
[[ -n "$MILESTONE" ]] && CMD+=(--milestone "$MILESTONE")
|
[[ -n "$MILESTONE" ]] && CMD+=(--milestone "$MILESTONE")
|
||||||
"${CMD[@]}"
|
prov_rc=0
|
||||||
|
"${CMD[@]}" || prov_rc=$?
|
||||||
|
[[ "$prov_rc" -eq 0 ]] || { echo "Error: provider command failed (exit ${prov_rc}; provider failure, not a usage error)" >&2; exit 1; }
|
||||||
echo "Updated GitHub issue #$ISSUE_NUMBER"
|
echo "Updated GitHub issue #$ISSUE_NUMBER"
|
||||||
elif [[ "$PLATFORM" == "gitea" ]]; then
|
elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||||
REPO_SLUG=$(get_repo_slug) || {
|
REPO_SLUG=$(get_repo_slug) || {
|
||||||
@@ -84,7 +100,9 @@ elif [[ "$PLATFORM" == "gitea" ]]; then
|
|||||||
[[ -n "$BODY" ]] && CMD+=(--description "$BODY")
|
[[ -n "$BODY" ]] && CMD+=(--description "$BODY")
|
||||||
[[ -n "$LABELS" ]] && CMD+=(--add-labels "$LABELS")
|
[[ -n "$LABELS" ]] && CMD+=(--add-labels "$LABELS")
|
||||||
[[ -n "$MILESTONE" ]] && CMD+=(--milestone "$MILESTONE")
|
[[ -n "$MILESTONE" ]] && CMD+=(--milestone "$MILESTONE")
|
||||||
"${CMD[@]}"
|
prov_rc=0
|
||||||
|
"${CMD[@]}" || prov_rc=$?
|
||||||
|
[[ "$prov_rc" -eq 0 ]] || { echo "Error: provider command failed (exit ${prov_rc}; provider failure, not a usage error)" >&2; exit 1; }
|
||||||
echo "Updated Gitea issue #$ISSUE_NUMBER"
|
echo "Updated Gitea issue #$ISSUE_NUMBER"
|
||||||
else
|
else
|
||||||
echo "Error: Unknown platform"
|
echo "Error: Unknown platform"
|
||||||
|
|||||||
@@ -36,33 +36,46 @@ Examples:
|
|||||||
$(basename "$0") -m "0.2.0" # Issues in milestone 0.2.0
|
$(basename "$0") -m "0.2.0" # Issues in milestone 0.2.0
|
||||||
$(basename "$0") --repo ddk/ai-bma # List issues from anywhere
|
$(basename "$0") --repo ddk/ai-bma # List issues from anywhere
|
||||||
EOF
|
EOF
|
||||||
exit "${1:-1}"
|
exit "${1:-2}"
|
||||||
}
|
}
|
||||||
|
|
||||||
# Parse arguments
|
# Parse arguments
|
||||||
|
# Usage-error contract (R4, 2026-08-28): usage errors print to STDERR and exit 2,
|
||||||
|
# distinct from provider, credential, and verification failures (exit 1).
|
||||||
|
usage_error() {
|
||||||
|
echo "Error: $*" >&2
|
||||||
|
usage >&2
|
||||||
|
}
|
||||||
|
|
||||||
while [[ $# -gt 0 ]]; do
|
while [[ $# -gt 0 ]]; do
|
||||||
case $1 in
|
case $1 in
|
||||||
-s|--state)
|
-s|--state)
|
||||||
|
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||||
STATE="$2"
|
STATE="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
-l|--label)
|
-l|--label)
|
||||||
|
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||||
LABEL="$2"
|
LABEL="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
-m|--milestone)
|
-m|--milestone)
|
||||||
|
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||||
MILESTONE="$2"
|
MILESTONE="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
-a|--assignee)
|
-a|--assignee)
|
||||||
|
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||||
ASSIGNEE="$2"
|
ASSIGNEE="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
-n|--limit)
|
-n|--limit)
|
||||||
|
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||||
LIMIT="$2"
|
LIMIT="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
-r|--repo)
|
-r|--repo)
|
||||||
|
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||||
REPO_OVERRIDE="$2"
|
REPO_OVERRIDE="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
@@ -95,7 +108,9 @@ case "$PLATFORM" in
|
|||||||
[[ -n "$LABEL" ]] && CMD+=(--label "$LABEL")
|
[[ -n "$LABEL" ]] && CMD+=(--label "$LABEL")
|
||||||
[[ -n "$MILESTONE" ]] && CMD+=(--milestone "$MILESTONE")
|
[[ -n "$MILESTONE" ]] && CMD+=(--milestone "$MILESTONE")
|
||||||
[[ -n "$ASSIGNEE" ]] && CMD+=(--assignee "$ASSIGNEE")
|
[[ -n "$ASSIGNEE" ]] && CMD+=(--assignee "$ASSIGNEE")
|
||||||
"${CMD[@]}"
|
prov_rc=0
|
||||||
|
"${CMD[@]}" || prov_rc=$?
|
||||||
|
[[ "$prov_rc" -eq 0 ]] || { echo "Error: provider command failed (exit ${prov_rc}; provider failure, not a usage error)" >&2; exit 1; }
|
||||||
;;
|
;;
|
||||||
gitea)
|
gitea)
|
||||||
if [[ -n "$REPO_OVERRIDE" ]]; then
|
if [[ -n "$REPO_OVERRIDE" ]]; then
|
||||||
@@ -114,7 +129,9 @@ case "$PLATFORM" in
|
|||||||
[[ -n "$MILESTONE" ]] && CMD+=(--milestones "$MILESTONE")
|
[[ -n "$MILESTONE" ]] && CMD+=(--milestones "$MILESTONE")
|
||||||
# Note: tea may not support assignee filter directly in all versions.
|
# Note: tea may not support assignee filter directly in all versions.
|
||||||
[[ -n "$ASSIGNEE" ]] && echo "Note: Assignee filtering may require manual review for Gitea" >&2
|
[[ -n "$ASSIGNEE" ]] && echo "Note: Assignee filtering may require manual review for Gitea" >&2
|
||||||
"${CMD[@]}"
|
prov_rc=0
|
||||||
|
"${CMD[@]}" || prov_rc=$?
|
||||||
|
[[ "$prov_rc" -eq 0 ]] || { echo "Error: provider command failed (exit ${prov_rc}; provider failure, not a usage error)" >&2; exit 1; }
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
echo "Error: Could not detect git platform" >&2
|
echo "Error: Could not detect git platform" >&2
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# issue-reopen.sh - Reopen a closed issue on GitHub or Gitea
|
# issue-reopen.sh - Reopen a closed issue on GitHub or Gitea
|
||||||
# Usage: issue-reopen.sh -i <issue_number> [-c <comment>]
|
# Usage: issue-reopen.sh -i <issue_number> [-b <comment>]
|
||||||
|
# (-c/--comment is a backward-compatible alias for -b/--body; R1/R4 2026-08-28)
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
@@ -11,35 +12,49 @@ source "$SCRIPT_DIR/detect-platform.sh"
|
|||||||
ISSUE_NUMBER=""
|
ISSUE_NUMBER=""
|
||||||
COMMENT=""
|
COMMENT=""
|
||||||
|
|
||||||
|
# Usage-error contract (R4, 2026-08-28): usage errors print to STDERR and exit 2,
|
||||||
|
# distinct from provider, credential, and verification failures (exit 1), so a
|
||||||
|
# caller or stop gate can tell an invocation defect from a delivery blocker.
|
||||||
|
usage_error() {
|
||||||
|
echo "Error: $*" >&2
|
||||||
|
echo "Usage: issue-reopen.sh -i <issue_number> [-b <comment>] (see --help)" >&2
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
|
||||||
while [[ $# -gt 0 ]]; do
|
while [[ $# -gt 0 ]]; do
|
||||||
case $1 in
|
case $1 in
|
||||||
-i|--issue)
|
-i|--issue)
|
||||||
|
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||||
ISSUE_NUMBER="$2"
|
ISSUE_NUMBER="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
-c|--comment)
|
-b|--body|-c|--comment)
|
||||||
|
# R1 (2026-08-28): --body is the canonical flag; -c/--comment stays
|
||||||
|
# a backward-compatible alias.
|
||||||
|
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||||
COMMENT="$2"
|
COMMENT="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
-h|--help)
|
-h|--help)
|
||||||
echo "Usage: issue-reopen.sh -i <issue_number> [-c <comment>]"
|
echo "Usage: issue-reopen.sh -i <issue_number> [-b <comment>]"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Options:"
|
echo "Options:"
|
||||||
echo " -i, --issue Issue number (required)"
|
echo " -i, --issue Issue number (required)"
|
||||||
echo " -c, --comment Comment to add when reopening (optional)"
|
echo " -b, --body Comment to add when reopening (optional; canonical)"
|
||||||
|
echo " -c, --comment Alias for --body"
|
||||||
echo " -h, --help Show this help"
|
echo " -h, --help Show this help"
|
||||||
|
echo ""
|
||||||
|
echo "Exit codes: 0 success; 2 usage error (stderr); 1 provider/credential/verification failure."
|
||||||
exit 0
|
exit 0
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
echo "Unknown option: $1"
|
usage_error "unknown option: $1"
|
||||||
exit 1
|
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
if [[ -z "$ISSUE_NUMBER" ]]; then
|
if [[ -z "$ISSUE_NUMBER" ]]; then
|
||||||
echo "Error: Issue number is required (-i)"
|
usage_error "issue number is required (-i/--issue)"
|
||||||
exit 1
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
detect_platform >/dev/null
|
detect_platform >/dev/null
|
||||||
@@ -80,18 +95,34 @@ gitea_issue_reopen_api() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if [[ "$PLATFORM" == "github" ]]; then
|
if [[ "$PLATFORM" == "github" ]]; then
|
||||||
|
# R4: normalize provider failures to exit 1 (gh's own usage errors exit 2
|
||||||
|
# and would collide with the reserved usage-error status).
|
||||||
if [[ -n "$COMMENT" ]]; then
|
if [[ -n "$COMMENT" ]]; then
|
||||||
gh issue comment "$ISSUE_NUMBER" --body "$COMMENT"
|
gh_rc=0
|
||||||
|
gh issue comment "$ISSUE_NUMBER" --body "$COMMENT" || gh_rc=$?
|
||||||
|
if [[ "$gh_rc" -ne 0 ]]; then
|
||||||
|
echo "Error: GitHub comment before reopen failed (gh exit $gh_rc)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
gh_rc=0
|
||||||
|
gh issue reopen "$ISSUE_NUMBER" || gh_rc=$?
|
||||||
|
if [[ "$gh_rc" -ne 0 ]]; then
|
||||||
|
echo "Error: GitHub issue reopen failed (gh exit $gh_rc)" >&2
|
||||||
|
exit 1
|
||||||
fi
|
fi
|
||||||
gh issue reopen "$ISSUE_NUMBER"
|
|
||||||
echo "Reopened GitHub issue #$ISSUE_NUMBER"
|
echo "Reopened GitHub issue #$ISSUE_NUMBER"
|
||||||
elif [[ "$PLATFORM" == "gitea" ]]; then
|
elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||||
REPO_ARGS=$(get_gitea_repo_args || true)
|
REPO_ARGS=$(get_gitea_repo_args || true)
|
||||||
if [[ -n "$REPO_ARGS" ]]; then
|
if [[ -n "$REPO_ARGS" ]]; then
|
||||||
if [[ -n "$COMMENT" ]]; then
|
if [[ -n "$COMMENT" ]]; then
|
||||||
tea issue comment "$ISSUE_NUMBER" "$COMMENT" $REPO_ARGS
|
prov_rc=0
|
||||||
|
tea issue comment "$ISSUE_NUMBER" "$COMMENT" $REPO_ARGS || prov_rc=$?
|
||||||
|
[[ "$prov_rc" -eq 0 ]] || { echo "Error: provider command failed (exit ${prov_rc}; provider failure, not a usage error)" >&2; exit 1; }
|
||||||
fi
|
fi
|
||||||
tea issue reopen "$ISSUE_NUMBER" $REPO_ARGS
|
prov_rc=0
|
||||||
|
tea issue reopen "$ISSUE_NUMBER" $REPO_ARGS || prov_rc=$?
|
||||||
|
[[ "$prov_rc" -eq 0 ]] || { echo "Error: provider command failed (exit ${prov_rc}; provider failure, not a usage error)" >&2; exit 1; }
|
||||||
else
|
else
|
||||||
echo "No tea login configured for $(get_remote_host); using authenticated Gitea API fallback." >&2
|
echo "No tea login configured for $(get_remote_host); using authenticated Gitea API fallback." >&2
|
||||||
if [[ -n "$COMMENT" ]]; then
|
if [[ -n "$COMMENT" ]]; then
|
||||||
|
|||||||
@@ -8,6 +8,14 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|||||||
source "$SCRIPT_DIR/detect-platform.sh"
|
source "$SCRIPT_DIR/detect-platform.sh"
|
||||||
|
|
||||||
# Parse arguments
|
# Parse arguments
|
||||||
|
# Usage-error contract (R4, 2026-08-28): usage errors print to STDERR and exit 2,
|
||||||
|
# distinct from provider, credential, and verification failures (exit 1).
|
||||||
|
usage_error() {
|
||||||
|
echo "Error: $*" >&2
|
||||||
|
echo "Usage: issue-view.sh -i <issue_number> (see --help)" >&2
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
|
||||||
ISSUE_NUMBER=""
|
ISSUE_NUMBER=""
|
||||||
|
|
||||||
# get_remote_host and get_gitea_token are provided by detect-platform.sh
|
# get_remote_host and get_gitea_token are provided by detect-platform.sh
|
||||||
@@ -74,6 +82,7 @@ if comments:
|
|||||||
while [[ $# -gt 0 ]]; do
|
while [[ $# -gt 0 ]]; do
|
||||||
case $1 in
|
case $1 in
|
||||||
-i|--issue)
|
-i|--issue)
|
||||||
|
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||||
ISSUE_NUMBER="$2"
|
ISSUE_NUMBER="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
@@ -88,21 +97,21 @@ while [[ $# -gt 0 ]]; do
|
|||||||
exit 0
|
exit 0
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
echo "Unknown option: $1"
|
usage_error "unknown option: $1"
|
||||||
exit 1
|
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
if [[ -z "$ISSUE_NUMBER" ]]; then
|
if [[ -z "$ISSUE_NUMBER" ]]; then
|
||||||
echo "Error: Issue number is required (-i)"
|
usage_error "Issue number is required"
|
||||||
exit 1
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
detect_platform >/dev/null
|
detect_platform >/dev/null
|
||||||
|
|
||||||
if [[ "$PLATFORM" == "github" ]]; then
|
if [[ "$PLATFORM" == "github" ]]; then
|
||||||
gh issue view "$ISSUE_NUMBER"
|
prov_rc=0
|
||||||
|
gh issue view "$ISSUE_NUMBER" || prov_rc=$?
|
||||||
|
[[ "$prov_rc" -eq 0 ]] || { echo "Error: provider command failed (exit ${prov_rc}; provider failure, not a usage error)" >&2; exit 1; }
|
||||||
elif [[ "$PLATFORM" == "gitea" ]]; then
|
elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||||
if command -v tea >/dev/null 2>&1; then
|
if command -v tea >/dev/null 2>&1; then
|
||||||
# --comments is what makes tea print the comment bodies (#1357 F3).
|
# --comments is what makes tea print the comment bodies (#1357 F3).
|
||||||
|
|||||||
@@ -28,18 +28,25 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|||||||
source "$SCRIPT_DIR/detect-platform.sh"
|
source "$SCRIPT_DIR/detect-platform.sh"
|
||||||
|
|
||||||
REPO="" MILESTONE="" LABEL="" LOGIN="" LIMIT=100
|
REPO="" MILESTONE="" LABEL="" LOGIN="" LIMIT=100
|
||||||
while getopts "r:m:l:L:n:h" opt; do
|
# R2 (2026-08-28): long-flag aliases with the same usage-error contract the
|
||||||
case "$opt" in
|
# wrapper family shares (rc 2, stderr). getopts could not take long flags.
|
||||||
r) REPO="$OPTARG" ;;
|
usage_error() {
|
||||||
m) MILESTONE="$OPTARG" ;;
|
echo "Error: $*" >&2
|
||||||
l) LABEL="$OPTARG" ;;
|
echo "Usage: lane-brief.sh -r <owner/repo> [-m milestone] [-l label] [-L login] [-n limit]" >&2
|
||||||
L) LOGIN="$OPTARG" ;;
|
exit 2
|
||||||
n) LIMIT="$OPTARG" ;;
|
}
|
||||||
h) grep '^#' "$0" | sed 's/^# \?//'; exit 0 ;;
|
while [[ $# -gt 0 ]]; do
|
||||||
*) echo "see -h" >&2; exit 2 ;;
|
case "$1" in
|
||||||
|
-r|--repo) [[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"; REPO="$2"; shift 2 ;;
|
||||||
|
-m|--milestone) [[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"; MILESTONE="$2"; shift 2 ;;
|
||||||
|
-l|--label|--labels) [[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"; LABEL="$2"; shift 2 ;;
|
||||||
|
-L|--login) [[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"; LOGIN="$2"; shift 2 ;;
|
||||||
|
-n|--limit) [[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"; LIMIT="$2"; shift 2 ;;
|
||||||
|
-h|--help) grep '^#' "$0" | sed 's/^# \?//'; exit 0 ;;
|
||||||
|
*) usage_error "unknown option: $1" ;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
[[ -n "$REPO" ]] || { echo "FATAL: -r <owner/repo> required" >&2; exit 2; }
|
[[ -n "$REPO" ]] || usage_error "-r/--repo <owner/repo> required"
|
||||||
|
|
||||||
# Resolve login: explicit -L, then $GITEA_LOGIN, then owner inference, then the
|
# Resolve login: explicit -L, then $GITEA_LOGIN, then owner inference, then the
|
||||||
# shared default-login resolver. Owner inference comes before the shared fallback
|
# shared default-login resolver. Owner inference comes before the shared fallback
|
||||||
@@ -72,7 +79,7 @@ if [[ -z "$LOGIN" ]]; then
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
[[ -n "$LOGIN" ]] || { echo "FATAL: could not resolve a Gitea login for $REPO (pass -L or set GITEA_LOGIN)" >&2; exit 2; }
|
[[ -n "$LOGIN" ]] || { echo "FATAL: could not resolve a Gitea login for $REPO (pass -L or set GITEA_LOGIN)" >&2; exit 1; }
|
||||||
|
|
||||||
command -v tea >/dev/null || { echo "FATAL: tea not found" >&2; exit 1; }
|
command -v tea >/dev/null || { echo "FATAL: tea not found" >&2; exit 1; }
|
||||||
command -v jq >/dev/null || { echo "FATAL: jq not found" >&2; exit 1; }
|
command -v jq >/dev/null || { echo "FATAL: jq not found" >&2; exit 1; }
|
||||||
|
|||||||
@@ -8,11 +8,20 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|||||||
source "$SCRIPT_DIR/detect-platform.sh"
|
source "$SCRIPT_DIR/detect-platform.sh"
|
||||||
|
|
||||||
# Parse arguments
|
# Parse arguments
|
||||||
|
# Usage-error contract (R4, 2026-08-28): usage errors print to STDERR and exit 2,
|
||||||
|
# distinct from provider, credential, and verification failures (exit 1).
|
||||||
|
usage_error() {
|
||||||
|
echo "Error: $*" >&2
|
||||||
|
echo "Usage: milestone-close.sh -t <title> (see --help)" >&2
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
|
||||||
TITLE=""
|
TITLE=""
|
||||||
|
|
||||||
while [[ $# -gt 0 ]]; do
|
while [[ $# -gt 0 ]]; do
|
||||||
case $1 in
|
case $1 in
|
||||||
-t|--title)
|
-t|--title)
|
||||||
|
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||||
TITLE="$2"
|
TITLE="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
@@ -25,28 +34,30 @@ while [[ $# -gt 0 ]]; do
|
|||||||
exit 0
|
exit 0
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
echo "Unknown option: $1"
|
usage_error "unknown option: $1"
|
||||||
exit 1
|
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
if [[ -z "$TITLE" ]]; then
|
if [[ -z "$TITLE" ]]; then
|
||||||
echo "Error: Milestone title is required (-t)"
|
usage_error "Milestone title is required"
|
||||||
exit 1
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
detect_platform >/dev/null
|
detect_platform >/dev/null
|
||||||
|
|
||||||
if [[ "$PLATFORM" == "github" ]]; then
|
if [[ "$PLATFORM" == "github" ]]; then
|
||||||
gh api -X PATCH "/repos/{owner}/{repo}/milestones/$(gh api "/repos/{owner}/{repo}/milestones" --jq ".[] | select(.title==\"$TITLE\") | .number")" -f state=closed
|
prov_rc=0
|
||||||
|
gh api -X PATCH "/repos/{owner}/{repo}/milestones/$(gh api "/repos/{owner}/{repo}/milestones" --jq ".[] | select(.title==\"$TITLE\") | .number")" -f state=closed || prov_rc=$?
|
||||||
|
[[ "$prov_rc" -eq 0 ]] || { echo "Error: provider command failed (exit ${prov_rc}; provider failure, not a usage error)" >&2; exit 1; }
|
||||||
echo "Closed GitHub milestone: $TITLE"
|
echo "Closed GitHub milestone: $TITLE"
|
||||||
elif [[ "$PLATFORM" == "gitea" ]]; then
|
elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||||
REPO_ARGS=$(get_gitea_repo_args) || {
|
REPO_ARGS=$(get_gitea_repo_args) || {
|
||||||
echo "Error: Could not resolve Gitea repo/login for remote host" >&2
|
echo "Error: Could not resolve Gitea repo/login for remote host" >&2
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
tea milestone close "$TITLE" $REPO_ARGS
|
prov_rc=0
|
||||||
|
tea milestone close "$TITLE" $REPO_ARGS || prov_rc=$?
|
||||||
|
[[ "$prov_rc" -eq 0 ]] || { echo "Error: provider command failed (exit ${prov_rc}; provider failure, not a usage error)" >&2; exit 1; }
|
||||||
echo "Closed Gitea milestone: $TITLE"
|
echo "Closed Gitea milestone: $TITLE"
|
||||||
else
|
else
|
||||||
echo "Error: Unknown platform"
|
echo "Error: Unknown platform"
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
# Usage: milestone-create.sh -t "Title" [-d "Description"] [--due "YYYY-MM-DD"]
|
# Usage: milestone-create.sh -t "Title" [-d "Description"] [--due "YYYY-MM-DD"]
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
set -o pipefail
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
source "$SCRIPT_DIR/detect-platform.sh"
|
source "$SCRIPT_DIR/detect-platform.sh"
|
||||||
@@ -37,21 +38,31 @@ Examples:
|
|||||||
$(basename "$0") -t "0.0.1" -d "Pre-MVP Foundation Sprint"
|
$(basename "$0") -t "0.0.1" -d "Pre-MVP Foundation Sprint"
|
||||||
$(basename "$0") -t "0.1.0" -d "MVP Release" --due "2025-03-01"
|
$(basename "$0") -t "0.1.0" -d "MVP Release" --due "2025-03-01"
|
||||||
EOF
|
EOF
|
||||||
exit "${1:-1}"
|
exit "${1:-2}"
|
||||||
}
|
}
|
||||||
|
|
||||||
# Parse arguments
|
# Parse arguments
|
||||||
|
# Usage-error contract (R4, 2026-08-28): usage errors print to STDERR and exit 2,
|
||||||
|
# distinct from provider, credential, and verification failures (exit 1).
|
||||||
|
usage_error() {
|
||||||
|
echo "Error: $*" >&2
|
||||||
|
usage >&2
|
||||||
|
}
|
||||||
|
|
||||||
while [[ $# -gt 0 ]]; do
|
while [[ $# -gt 0 ]]; do
|
||||||
case $1 in
|
case $1 in
|
||||||
-t|--title)
|
-t|--title)
|
||||||
|
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||||
TITLE="$2"
|
TITLE="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
-d|--desc)
|
-d|--desc)
|
||||||
|
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||||
DESCRIPTION="$2"
|
DESCRIPTION="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
--due)
|
--due)
|
||||||
|
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||||
DUE_DATE="$2"
|
DUE_DATE="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
@@ -74,14 +85,18 @@ PLATFORM=$(detect_platform)
|
|||||||
if [[ "$LIST_ONLY" == true ]]; then
|
if [[ "$LIST_ONLY" == true ]]; then
|
||||||
case "$PLATFORM" in
|
case "$PLATFORM" in
|
||||||
github)
|
github)
|
||||||
gh api repos/:owner/:repo/milestones --jq '.[] | "\(.number)\t\(.title)\t\(.state)\t\(.open_issues)/\(.closed_issues) issues"'
|
prov_rc=0
|
||||||
|
gh api repos/:owner/:repo/milestones --jq '.[] | "\(.number)\t\(.title)\t\(.state)\t\(.open_issues)/\(.closed_issues) issues"' || prov_rc=$?
|
||||||
|
[[ "$prov_rc" -eq 0 ]] || { echo "Error: provider command failed (exit ${prov_rc}; provider failure, not a usage error)" >&2; exit 1; }
|
||||||
;;
|
;;
|
||||||
gitea)
|
gitea)
|
||||||
REPO_ARGS=$(get_gitea_repo_args) || {
|
REPO_ARGS=$(get_gitea_repo_args) || {
|
||||||
echo "Error: Could not resolve Gitea repo/login for remote host" >&2
|
echo "Error: Could not resolve Gitea repo/login for remote host" >&2
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
tea milestones list $REPO_ARGS
|
prov_rc=0
|
||||||
|
tea milestones list $REPO_ARGS || prov_rc=$?
|
||||||
|
[[ "$prov_rc" -eq 0 ]] || { echo "Error: provider command failed (exit ${prov_rc}; provider failure, not a usage error)" >&2; exit 1; }
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
echo "Error: Could not detect git platform" >&2
|
echo "Error: Could not detect git platform" >&2
|
||||||
@@ -92,8 +107,7 @@ if [[ "$LIST_ONLY" == true ]]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ -z "$TITLE" ]]; then
|
if [[ -z "$TITLE" ]]; then
|
||||||
echo "Error: Title is required (-t) for creating milestones" >&2
|
usage_error "Title is required (-t) for creating milestones"
|
||||||
usage
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
case "$PLATFORM" in
|
case "$PLATFORM" in
|
||||||
@@ -109,7 +123,9 @@ case "$PLATFORM" in
|
|||||||
+ (if $d != "" then {"description": $d} else {} end)
|
+ (if $d != "" then {"description": $d} else {} end)
|
||||||
+ (if $due != "" then {"due_on": ($due + "T00:00:00Z")} else {} end)')
|
+ (if $due != "" then {"due_on": ($due + "T00:00:00Z")} else {} end)')
|
||||||
|
|
||||||
gh api repos/:owner/:repo/milestones --method POST --input - <<< "$JSON_PAYLOAD"
|
prov_rc=0
|
||||||
|
gh api repos/:owner/:repo/milestones --method POST --input - <<< "$JSON_PAYLOAD" || prov_rc=$?
|
||||||
|
[[ "$prov_rc" -eq 0 ]] || { echo "Error: provider command failed (exit ${prov_rc}; provider failure, not a usage error)" >&2; exit 1; }
|
||||||
echo "Milestone '$TITLE' created successfully"
|
echo "Milestone '$TITLE' created successfully"
|
||||||
;;
|
;;
|
||||||
gitea)
|
gitea)
|
||||||
@@ -120,7 +136,9 @@ case "$PLATFORM" in
|
|||||||
CMD=(tea milestones create --title "$TITLE")
|
CMD=(tea milestones create --title "$TITLE")
|
||||||
[[ -n "$DESCRIPTION" ]] && CMD+=(--description "$DESCRIPTION")
|
[[ -n "$DESCRIPTION" ]] && CMD+=(--description "$DESCRIPTION")
|
||||||
[[ -n "$DUE_DATE" ]] && CMD+=(--deadline "$DUE_DATE")
|
[[ -n "$DUE_DATE" ]] && CMD+=(--deadline "$DUE_DATE")
|
||||||
"${CMD[@]}" $REPO_ARGS
|
prov_rc=0
|
||||||
|
"${CMD[@]}" $REPO_ARGS || prov_rc=$?
|
||||||
|
[[ "$prov_rc" -eq 0 ]] || { echo "Error: provider command failed (exit ${prov_rc}; provider failure, not a usage error)" >&2; exit 1; }
|
||||||
echo "Milestone '$TITLE' created successfully"
|
echo "Milestone '$TITLE' created successfully"
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
|
|||||||
@@ -8,11 +8,20 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|||||||
source "$SCRIPT_DIR/detect-platform.sh"
|
source "$SCRIPT_DIR/detect-platform.sh"
|
||||||
|
|
||||||
# Parse arguments
|
# Parse arguments
|
||||||
|
# Usage-error contract (R4, 2026-08-28): usage errors print to STDERR and exit 2,
|
||||||
|
# distinct from provider, credential, and verification failures (exit 1).
|
||||||
|
usage_error() {
|
||||||
|
echo "Error: $*" >&2
|
||||||
|
echo "Usage: milestone-list.sh [-s <state>] (see --help)" >&2
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
|
||||||
STATE="open"
|
STATE="open"
|
||||||
|
|
||||||
while [[ $# -gt 0 ]]; do
|
while [[ $# -gt 0 ]]; do
|
||||||
case $1 in
|
case $1 in
|
||||||
-s|--state)
|
-s|--state)
|
||||||
|
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||||
STATE="$2"
|
STATE="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
@@ -25,8 +34,7 @@ while [[ $# -gt 0 ]]; do
|
|||||||
exit 0
|
exit 0
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
echo "Unknown option: $1"
|
usage_error "unknown option: $1"
|
||||||
exit 1
|
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
@@ -34,13 +42,17 @@ done
|
|||||||
detect_platform >/dev/null
|
detect_platform >/dev/null
|
||||||
|
|
||||||
if [[ "$PLATFORM" == "github" ]]; then
|
if [[ "$PLATFORM" == "github" ]]; then
|
||||||
gh api "/repos/{owner}/{repo}/milestones?state=$STATE" --jq '.[] | "\(.title) (\(.state)) - \(.open_issues) open, \(.closed_issues) closed"'
|
prov_rc=0
|
||||||
|
gh api "/repos/{owner}/{repo}/milestones?state=$STATE" --jq '.[] | "\(.title) (\(.state)) - \(.open_issues) open, \(.closed_issues) closed"' || prov_rc=$?
|
||||||
|
[[ "$prov_rc" -eq 0 ]] || { echo "Error: provider command failed (exit ${prov_rc}; provider failure, not a usage error)" >&2; exit 1; }
|
||||||
elif [[ "$PLATFORM" == "gitea" ]]; then
|
elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||||
REPO_ARGS=$(get_gitea_repo_args) || {
|
REPO_ARGS=$(get_gitea_repo_args) || {
|
||||||
echo "Error: Could not resolve Gitea repo/login for remote host" >&2
|
echo "Error: Could not resolve Gitea repo/login for remote host" >&2
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
tea milestone list $REPO_ARGS
|
prov_rc=0
|
||||||
|
tea milestone list $REPO_ARGS || prov_rc=$?
|
||||||
|
[[ "$prov_rc" -eq 0 ]] || { echo "Error: provider command failed (exit ${prov_rc}; provider failure, not a usage error)" >&2; exit 1; }
|
||||||
else
|
else
|
||||||
echo "Error: Unknown platform"
|
echo "Error: Unknown platform"
|
||||||
exit 1
|
exit 1
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# pr-close.sh - Close a pull request without merging on GitHub or Gitea
|
# pr-close.sh - Close a pull request without merging on GitHub or Gitea
|
||||||
# Usage: pr-close.sh -n <pr_number> [-c <comment>]
|
# Usage: pr-close.sh -n <pr_number> [-b <comment>]
|
||||||
|
# (-c/--comment is a backward-compatible alias for -b/--body; R1/R4 2026-08-28)
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
@@ -11,50 +12,80 @@ source "$SCRIPT_DIR/detect-platform.sh"
|
|||||||
PR_NUMBER=""
|
PR_NUMBER=""
|
||||||
COMMENT=""
|
COMMENT=""
|
||||||
|
|
||||||
|
# Usage-error contract (R4, 2026-08-28): usage errors print to STDERR and exit 2,
|
||||||
|
# distinct from provider, credential, and verification failures (exit 1), so a
|
||||||
|
# caller or stop gate can tell an invocation defect from a delivery blocker.
|
||||||
|
usage_error() {
|
||||||
|
echo "Error: $*" >&2
|
||||||
|
echo "Usage: pr-close.sh -n <pr_number> [-b <comment>] (see --help)" >&2
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
|
||||||
while [[ $# -gt 0 ]]; do
|
while [[ $# -gt 0 ]]; do
|
||||||
case $1 in
|
case $1 in
|
||||||
-n|--number)
|
-n|--number)
|
||||||
|
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||||
PR_NUMBER="$2"
|
PR_NUMBER="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
-c|--comment)
|
-b|--body|-c|--comment)
|
||||||
|
# R1 (2026-08-28): --body is the canonical flag; -c/--comment stays
|
||||||
|
# a backward-compatible alias.
|
||||||
|
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||||
COMMENT="$2"
|
COMMENT="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
-h|--help)
|
-h|--help)
|
||||||
echo "Usage: pr-close.sh -n <pr_number> [-c <comment>]"
|
echo "Usage: pr-close.sh -n <pr_number> [-b <comment>]"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Options:"
|
echo "Options:"
|
||||||
echo " -n, --number PR number (required)"
|
echo " -n, --number PR number (required)"
|
||||||
echo " -c, --comment Comment before closing (optional)"
|
echo " -b, --body Comment before closing (optional; canonical)"
|
||||||
|
echo " -c, --comment Alias for --body"
|
||||||
echo " -h, --help Show this help"
|
echo " -h, --help Show this help"
|
||||||
|
echo ""
|
||||||
|
echo "Exit codes: 0 success; 2 usage error (stderr); 1 provider/credential/verification failure."
|
||||||
exit 0
|
exit 0
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
echo "Unknown option: $1"
|
usage_error "unknown option: $1"
|
||||||
exit 1
|
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
if [[ -z "$PR_NUMBER" ]]; then
|
if [[ -z "$PR_NUMBER" ]]; then
|
||||||
echo "Error: PR number is required (-n)"
|
usage_error "PR number is required (-n/--number)"
|
||||||
exit 1
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
detect_platform >/dev/null
|
detect_platform >/dev/null
|
||||||
|
|
||||||
if [[ "$PLATFORM" == "github" ]]; then
|
if [[ "$PLATFORM" == "github" ]]; then
|
||||||
|
# R4: normalize provider failures to exit 1 (gh's own usage errors exit 2
|
||||||
|
# and would collide with the reserved usage-error status).
|
||||||
if [[ -n "$COMMENT" ]]; then
|
if [[ -n "$COMMENT" ]]; then
|
||||||
gh pr comment "$PR_NUMBER" --body "$COMMENT"
|
gh_rc=0
|
||||||
|
gh pr comment "$PR_NUMBER" --body "$COMMENT" || gh_rc=$?
|
||||||
|
if [[ "$gh_rc" -ne 0 ]]; then
|
||||||
|
echo "Error: GitHub PR comment before close failed (gh exit $gh_rc)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
gh_rc=0
|
||||||
|
gh pr close "$PR_NUMBER" || gh_rc=$?
|
||||||
|
if [[ "$gh_rc" -ne 0 ]]; then
|
||||||
|
echo "Error: GitHub PR close failed (gh exit $gh_rc)" >&2
|
||||||
|
exit 1
|
||||||
fi
|
fi
|
||||||
gh pr close "$PR_NUMBER"
|
|
||||||
echo "Closed GitHub PR #$PR_NUMBER"
|
echo "Closed GitHub PR #$PR_NUMBER"
|
||||||
elif [[ "$PLATFORM" == "gitea" ]]; then
|
elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||||
if [[ -n "$COMMENT" ]]; then
|
if [[ -n "$COMMENT" ]]; then
|
||||||
tea pr comment "$PR_NUMBER" "$COMMENT" $(get_gitea_repo_args)
|
prov_rc=0
|
||||||
|
tea pr comment "$PR_NUMBER" "$COMMENT" $(get_gitea_repo_args) || prov_rc=$?
|
||||||
|
[[ "$prov_rc" -eq 0 ]] || { echo "Error: provider command failed (exit ${prov_rc}; provider failure, not a usage error)" >&2; exit 1; }
|
||||||
fi
|
fi
|
||||||
tea pr close "$PR_NUMBER" $(get_gitea_repo_args)
|
prov_rc=0
|
||||||
|
tea pr close "$PR_NUMBER" $(get_gitea_repo_args) || prov_rc=$?
|
||||||
|
[[ "$prov_rc" -eq 0 ]] || { echo "Error: provider command failed (exit ${prov_rc}; provider failure, not a usage error)" >&2; exit 1; }
|
||||||
echo "Closed Gitea PR #$PR_NUMBER"
|
echo "Closed Gitea PR #$PR_NUMBER"
|
||||||
else
|
else
|
||||||
echo "Error: Unknown platform"
|
echo "Error: Unknown platform"
|
||||||
|
|||||||
@@ -135,37 +135,51 @@ Examples:
|
|||||||
$(basename "$0") -i 42 -b "Implements the feature described in #42"
|
$(basename "$0") -i 42 -b "Implements the feature described in #42"
|
||||||
$(basename "$0") -t "WIP: New feature" --draft
|
$(basename "$0") -t "WIP: New feature" --draft
|
||||||
EOF
|
EOF
|
||||||
exit "${1:-1}"
|
exit "${1:-2}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Usage-error contract (R4, 2026-08-28): usage errors print to STDERR and exit 2,
|
||||||
|
# distinct from provider, credential, and verification failures (exit 1).
|
||||||
|
usage_error() {
|
||||||
|
echo "Error: $*" >&2
|
||||||
|
usage >&2
|
||||||
}
|
}
|
||||||
|
|
||||||
# Parse arguments
|
# Parse arguments
|
||||||
while [[ $# -gt 0 ]]; do
|
while [[ $# -gt 0 ]]; do
|
||||||
case $1 in
|
case $1 in
|
||||||
-t|--title)
|
-t|--title)
|
||||||
|
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||||
TITLE="$2"
|
TITLE="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
-b|--body)
|
-b|--body)
|
||||||
|
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||||
BODY="$2"
|
BODY="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
-B|--base)
|
-B|--base)
|
||||||
|
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||||
BASE_BRANCH="$2"
|
BASE_BRANCH="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
-H|--head)
|
-H|--head)
|
||||||
|
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||||
HEAD_BRANCH="$2"
|
HEAD_BRANCH="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
-l|--labels)
|
-l|--labels)
|
||||||
|
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||||
LABELS="$2"
|
LABELS="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
-m|--milestone)
|
-m|--milestone)
|
||||||
|
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||||
MILESTONE="$2"
|
MILESTONE="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
-i|--issue)
|
-i|--issue)
|
||||||
|
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||||
ISSUE="$2"
|
ISSUE="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
@@ -266,7 +280,9 @@ case "$PLATFORM" in
|
|||||||
[[ -n "$LABELS" ]] && CMD+=(--label "$LABELS")
|
[[ -n "$LABELS" ]] && CMD+=(--label "$LABELS")
|
||||||
[[ -n "$MILESTONE" ]] && CMD+=(--milestone "$MILESTONE")
|
[[ -n "$MILESTONE" ]] && CMD+=(--milestone "$MILESTONE")
|
||||||
[[ "$DRAFT" == true ]] && CMD+=(--draft)
|
[[ "$DRAFT" == true ]] && CMD+=(--draft)
|
||||||
"${CMD[@]}"
|
prov_rc=0
|
||||||
|
"${CMD[@]}" || prov_rc=$?
|
||||||
|
[[ "$prov_rc" -eq 0 ]] || { echo "Error: provider command failed (exit ${prov_rc}; provider failure, not a usage error)" >&2; exit 1; }
|
||||||
;;
|
;;
|
||||||
gitea)
|
gitea)
|
||||||
# tea pull create syntax. Always pass --repo because tea repo inference
|
# tea pull create syntax. Always pass --repo because tea repo inference
|
||||||
|
|||||||
@@ -50,38 +50,45 @@ Options:
|
|||||||
-H, --host HOST Explicit Gitea host (required with --repo off-host)
|
-H, --host HOST Explicit Gitea host (required with --repo off-host)
|
||||||
-h, --help Show this help message
|
-h, --help Show this help message
|
||||||
EOF
|
EOF
|
||||||
exit "${1:-1}"
|
exit "${1:-2}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Usage-error contract (R4, 2026-08-28): usage errors print to STDERR and exit 2,
|
||||||
|
# distinct from provider, credential, and verification failures (exit 1).
|
||||||
|
usage_error() {
|
||||||
|
echo "Error: $*" >&2
|
||||||
|
usage >&2
|
||||||
}
|
}
|
||||||
|
|
||||||
while [[ $# -gt 0 ]]; do
|
while [[ $# -gt 0 ]]; do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
-n|--number) PR_NUMBER="${2:-}"; shift 2 ;;
|
-n|--number) [[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"; PR_NUMBER="${2:-}"; shift 2 ;;
|
||||||
-t|--title) TITLE="${2:-}"; shift 2 ;;
|
-t|--title) [[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"; TITLE="${2:-}"; shift 2 ;;
|
||||||
-b|--body) BODY="${2:-}"; shift 2 ;;
|
-b|--body) [[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"; BODY="${2:-}"; shift 2 ;;
|
||||||
-B|--base) BASE_BRANCH="${2:-}"; shift 2 ;;
|
-B|--base) [[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"; BASE_BRANCH="${2:-}"; shift 2 ;;
|
||||||
--draft)
|
--draft)
|
||||||
[[ "$DRAFT_MODE" != "ready" ]] || { echo "Error: --draft and --ready are mutually exclusive" >&2; exit 1; }
|
[[ "$DRAFT_MODE" != "ready" ]] || { echo "Error: --draft and --ready are mutually exclusive" >&2; exit 2; }
|
||||||
DRAFT_MODE="draft"; shift ;;
|
DRAFT_MODE="draft"; shift ;;
|
||||||
--ready)
|
--ready)
|
||||||
[[ "$DRAFT_MODE" != "draft" ]] || { echo "Error: --draft and --ready are mutually exclusive" >&2; exit 1; }
|
[[ "$DRAFT_MODE" != "draft" ]] || { echo "Error: --draft and --ready are mutually exclusive" >&2; exit 2; }
|
||||||
DRAFT_MODE="ready"; shift ;;
|
DRAFT_MODE="ready"; shift ;;
|
||||||
-l|--login) LOGIN_OVERRIDE="${2:-}"; shift 2 ;;
|
-l|--login) [[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"; LOGIN_OVERRIDE="${2:-}"; shift 2 ;;
|
||||||
-r|--repo) REPO_OVERRIDE="${2:-}"; shift 2 ;;
|
-r|--repo) [[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"; REPO_OVERRIDE="${2:-}"; shift 2 ;;
|
||||||
-H|--host) HOST_OVERRIDE="${2:-}"; shift 2 ;;
|
-H|--host) [[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"; HOST_OVERRIDE="${2:-}"; shift 2 ;;
|
||||||
-h|--help) usage 0 ;;
|
-h|--help) usage 0 ;;
|
||||||
*) echo "Unknown option: $1" >&2; usage ;;
|
*) echo "Unknown option: $1" >&2; usage ;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
[[ -n "$PR_NUMBER" ]] || { echo "Error: Pull request number is required (-n)" >&2; exit 1; }
|
[[ -n "$PR_NUMBER" ]] || { echo "Error: Pull request number is required (-n)" >&2; exit 2; }
|
||||||
[[ "$PR_NUMBER" =~ ^[1-9][0-9]*$ ]] || { echo "Error: Pull request number must be a positive integer" >&2; exit 1; }
|
[[ "$PR_NUMBER" =~ ^[1-9][0-9]*$ ]] || { echo "Error: Pull request number must be a positive integer" >&2; exit 2; }
|
||||||
if [[ -z "$TITLE" && -z "$BODY" && -z "$BASE_BRANCH" && -z "$DRAFT_MODE" ]]; then
|
if [[ -z "$TITLE" && -z "$BODY" && -z "$BASE_BRANCH" && -z "$DRAFT_MODE" ]]; then
|
||||||
echo "Error: At least one edit option is required" >&2
|
echo "Error: At least one edit option is required" >&2
|
||||||
exit 1
|
exit 2
|
||||||
fi
|
fi
|
||||||
[[ -z "$REPO_OVERRIDE" || "$REPO_OVERRIDE" =~ ^[^/[:space:]]+/[^/[:space:]]+$ ]] || {
|
[[ -z "$REPO_OVERRIDE" || "$REPO_OVERRIDE" =~ ^[^/[:space:]]+/[^/[:space:]]+$ ]] || {
|
||||||
echo "Error: --repo must be OWNER/REPO" >&2
|
echo "Error: --repo must be OWNER/REPO" >&2
|
||||||
exit 1
|
exit 2
|
||||||
}
|
}
|
||||||
|
|
||||||
if [[ -n "$HOST_OVERRIDE" || -n "$REPO_OVERRIDE" ]]; then
|
if [[ -n "$HOST_OVERRIDE" || -n "$REPO_OVERRIDE" ]]; then
|
||||||
@@ -92,18 +99,24 @@ fi
|
|||||||
|
|
||||||
case "$PLATFORM" in
|
case "$PLATFORM" in
|
||||||
github)
|
github)
|
||||||
[[ -z "$LOGIN_OVERRIDE" ]] || { echo "Error: --login is only valid for Gitea" >&2; exit 1; }
|
[[ -z "$LOGIN_OVERRIDE" ]] || { echo "Error: --login is only valid for Gitea" >&2; exit 2; }
|
||||||
if [[ -n "$TITLE" || -n "$BODY" || -n "$BASE_BRANCH" ]]; then
|
if [[ -n "$TITLE" || -n "$BODY" || -n "$BASE_BRANCH" ]]; then
|
||||||
CMD=(gh pr edit "$PR_NUMBER")
|
CMD=(gh pr edit "$PR_NUMBER")
|
||||||
[[ -n "$TITLE" ]] && CMD+=(--title "$TITLE")
|
[[ -n "$TITLE" ]] && CMD+=(--title "$TITLE")
|
||||||
[[ -n "$BODY" ]] && CMD+=(--body "$BODY")
|
[[ -n "$BODY" ]] && CMD+=(--body "$BODY")
|
||||||
[[ -n "$BASE_BRANCH" ]] && CMD+=(--base "$BASE_BRANCH")
|
[[ -n "$BASE_BRANCH" ]] && CMD+=(--base "$BASE_BRANCH")
|
||||||
"${CMD[@]}"
|
prov_rc=0
|
||||||
|
"${CMD[@]}" || prov_rc=$?
|
||||||
|
[[ "$prov_rc" -eq 0 ]] || { echo "Error: provider command failed (exit ${prov_rc}; provider failure, not a usage error)" >&2; exit 1; }
|
||||||
fi
|
fi
|
||||||
if [[ "$DRAFT_MODE" == "draft" ]]; then
|
if [[ "$DRAFT_MODE" == "draft" ]]; then
|
||||||
gh pr ready "$PR_NUMBER" --undo
|
prov_rc=0
|
||||||
|
gh pr ready "$PR_NUMBER" --undo || prov_rc=$?
|
||||||
|
[[ "$prov_rc" -eq 0 ]] || { echo "Error: provider command failed (exit ${prov_rc}; provider failure, not a usage error)" >&2; exit 1; }
|
||||||
elif [[ "$DRAFT_MODE" == "ready" ]]; then
|
elif [[ "$DRAFT_MODE" == "ready" ]]; then
|
||||||
gh pr ready "$PR_NUMBER"
|
prov_rc=0
|
||||||
|
gh pr ready "$PR_NUMBER" || prov_rc=$?
|
||||||
|
[[ "$prov_rc" -eq 0 ]] || { echo "Error: provider command failed (exit ${prov_rc}; provider failure, not a usage error)" >&2; exit 1; }
|
||||||
fi
|
fi
|
||||||
;;
|
;;
|
||||||
gitea)
|
gitea)
|
||||||
|
|||||||
@@ -43,60 +43,92 @@ LOGIN_OVERRIDE=""
|
|||||||
REPO_OVERRIDE=""
|
REPO_OVERRIDE=""
|
||||||
HOST_OVERRIDE=""
|
HOST_OVERRIDE=""
|
||||||
|
|
||||||
|
# Usage-error contract (R4, 2026-08-28): usage errors print to STDERR and exit 2,
|
||||||
|
# distinct from provider, credential, and verification failures (exit 1), so a
|
||||||
|
# caller or stop gate can tell an invocation defect from a delivery blocker.
|
||||||
|
usage_error() {
|
||||||
|
echo "Error: $*" >&2
|
||||||
|
echo "Usage: pr-review.sh -n <pr_number> -a <action> [-b <comment>] (see --help)" >&2
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
|
||||||
while [[ $# -gt 0 ]]; do
|
while [[ $# -gt 0 ]]; do
|
||||||
case $1 in
|
case $1 in
|
||||||
-n|--number)
|
-n|--number)
|
||||||
|
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||||
PR_NUMBER="$2"
|
PR_NUMBER="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
-a|--action)
|
-a|--action)
|
||||||
|
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||||
ACTION="$2"
|
ACTION="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
-c|--comment)
|
-b|--body|-c|--comment)
|
||||||
|
# R1 (2026-08-28): --body is the canonical flag; -c/--comment stays
|
||||||
|
# a backward-compatible alias.
|
||||||
|
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||||
COMMENT="$2"
|
COMMENT="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
-l|--login)
|
-l|--login)
|
||||||
|
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||||
LOGIN_OVERRIDE="$2"
|
LOGIN_OVERRIDE="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
-r|--repo)
|
-r|--repo)
|
||||||
|
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||||
REPO_OVERRIDE="$2"
|
REPO_OVERRIDE="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
-H|--host)
|
-H|--host)
|
||||||
|
[[ $# -ge 2 && "$2" != - && "$2" != --* && ! "$2" =~ ^-[[:alnum:]] ]] || usage_error "option $1 requires a value (option-like values are rejected; bare - is reserved)"
|
||||||
HOST_OVERRIDE="$2"
|
HOST_OVERRIDE="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
-h|--help)
|
-h|--help)
|
||||||
echo "Usage: pr-review.sh -n <pr_number> -a <action> [-c <comment>] [--login <name>] [-r owner/repo] [-H host]"
|
echo "Usage: pr-review.sh -n <pr_number> -a <action> [-b <comment>] [--login <name>] [-r owner/repo] [-H host]"
|
||||||
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 " -b, --body Review comment (required for request-changes; canonical)"
|
||||||
|
echo " -c, --comment Alias for --body"
|
||||||
echo " -l, --login Override the detected Gitea tea login (approve/request-changes only)"
|
echo " -l, --login Override the detected Gitea tea login (approve/request-changes only)"
|
||||||
echo " -r, --repo Explicit owner/repo slug (skips git-remote slug inference)"
|
echo " -r, --repo Explicit owner/repo slug (skips git-remote slug inference)"
|
||||||
echo " -H, --host Explicit Gitea host (skips remote-host inference)"
|
echo " -H, --host Explicit Gitea host (skips remote-host inference)"
|
||||||
echo " -h, --help Show this help"
|
echo " -h, --help Show this help"
|
||||||
|
echo ""
|
||||||
|
echo "Exit codes: 0 success; 2 usage error (stderr); 1 provider/credential/verification failure."
|
||||||
exit 0
|
exit 0
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
echo "Unknown option: $1"
|
usage_error "unknown option: $1"
|
||||||
exit 1
|
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
if [[ -z "$PR_NUMBER" ]]; then
|
if [[ -z "$PR_NUMBER" ]]; then
|
||||||
echo "Error: PR number is required (-n)"
|
usage_error "PR number is required (-n/--number)"
|
||||||
exit 1
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ -z "$ACTION" ]]; then
|
if [[ -z "$ACTION" ]]; then
|
||||||
echo "Error: Action is required (-a): approve, request-changes, comment"
|
usage_error "Action is required (-a/--action): approve, request-changes, comment"
|
||||||
exit 1
|
fi
|
||||||
|
|
||||||
|
# Validate the action BEFORE any provider contact (codex review of PR #1464:
|
||||||
|
# an unsupported --action previously reached platform detection and could
|
||||||
|
# touch the provider before failing with a provider-class status).
|
||||||
|
case "$ACTION" in
|
||||||
|
approve|request-changes|comment) ;;
|
||||||
|
*) usage_error "unknown action '$ACTION': approve, request-changes, comment" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# Body-required actions fail fast too (codex follow-up on PR #1464):
|
||||||
|
# request-changes and comment both require a body; validate before any
|
||||||
|
# provider contact.
|
||||||
|
if [[ ( "$ACTION" == "request-changes" || "$ACTION" == "comment" ) && -z "$COMMENT" ]]; then
|
||||||
|
usage_error "comment required for $ACTION (-b/--body)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ -n "$REPO_OVERRIDE" ]]; then
|
if [[ -n "$REPO_OVERRIDE" ]]; then
|
||||||
@@ -679,15 +711,18 @@ PY
|
|||||||
if [[ "$PLATFORM" == "github" ]]; then
|
if [[ "$PLATFORM" == "github" ]]; then
|
||||||
case $ACTION in
|
case $ACTION in
|
||||||
approve)
|
approve)
|
||||||
gh pr review "$PR_NUMBER" --approve ${COMMENT:+--body "$COMMENT"}
|
gh_rc=0
|
||||||
|
gh pr review "$PR_NUMBER" --approve ${COMMENT:+--body "$COMMENT"} || gh_rc=$?
|
||||||
|
[[ "$gh_rc" -eq 0 ]] || { echo "Error: GitHub approve failed (gh exit $gh_rc; provider failure, not a usage error)" >&2; exit 1; }
|
||||||
echo "Approved GitHub PR #$PR_NUMBER"
|
echo "Approved GitHub PR #$PR_NUMBER"
|
||||||
;;
|
;;
|
||||||
request-changes)
|
request-changes)
|
||||||
if [[ -z "$COMMENT" ]]; then
|
if [[ -z "$COMMENT" ]]; then
|
||||||
echo "Error: Comment required for request-changes"
|
usage_error "comment required for request-changes (-b/--body)"
|
||||||
exit 1
|
|
||||||
fi
|
fi
|
||||||
gh pr review "$PR_NUMBER" --request-changes --body "$COMMENT"
|
gh_rc=0
|
||||||
|
gh pr review "$PR_NUMBER" --request-changes --body "$COMMENT" || gh_rc=$?
|
||||||
|
[[ "$gh_rc" -eq 0 ]] || { echo "Error: GitHub request-changes failed (gh exit $gh_rc; provider failure, not a usage error)" >&2; exit 1; }
|
||||||
echo "Requested changes on GitHub PR #$PR_NUMBER"
|
echo "Requested changes on GitHub PR #$PR_NUMBER"
|
||||||
;;
|
;;
|
||||||
comment)
|
comment)
|
||||||
@@ -695,12 +730,13 @@ if [[ "$PLATFORM" == "github" ]]; then
|
|||||||
echo "Error: Comment required"
|
echo "Error: Comment required"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
gh pr review "$PR_NUMBER" --comment --body "$COMMENT"
|
gh_rc=0
|
||||||
|
gh pr review "$PR_NUMBER" --comment --body "$COMMENT" || gh_rc=$?
|
||||||
|
[[ "$gh_rc" -eq 0 ]] || { echo "Error: GitHub review comment failed (gh exit $gh_rc; provider failure, not a usage error)" >&2; exit 1; }
|
||||||
echo "Added review comment to GitHub PR #$PR_NUMBER"
|
echo "Added review comment to GitHub PR #$PR_NUMBER"
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
echo "Error: Unknown action: $ACTION"
|
usage_error "unknown action: $ACTION"
|
||||||
exit 1
|
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
elif [[ "$PLATFORM" == "gitea" ]]; then
|
elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||||
@@ -738,8 +774,7 @@ elif [[ "$PLATFORM" == "gitea" ]]; then
|
|||||||
;;
|
;;
|
||||||
request-changes)
|
request-changes)
|
||||||
if [[ -z "$COMMENT" ]]; then
|
if [[ -z "$COMMENT" ]]; then
|
||||||
echo "Error: Comment required for request-changes"
|
usage_error "comment required for request-changes (-b/--body)"
|
||||||
exit 1
|
|
||||||
fi
|
fi
|
||||||
# Best-effort host for credential resolution only (gitea_resolve_api_for_login
|
# Best-effort host for credential resolution only (gitea_resolve_api_for_login
|
||||||
# below re-derives the real host from HOST_OVERRIDE/remote independently and
|
# below re-derives the real host from HOST_OVERRIDE/remote independently and
|
||||||
@@ -794,8 +829,7 @@ elif [[ "$PLATFORM" == "gitea" ]]; then
|
|||||||
echo "Added and verified comment on Gitea PR #$PR_NUMBER (comment ID $comment_id)"
|
echo "Added and verified comment on Gitea PR #$PR_NUMBER (comment ID $comment_id)"
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
echo "Error: Unknown action: $ACTION"
|
usage_error "unknown action: $ACTION"
|
||||||
exit 1
|
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Usage-error contract for issue-assign.sh (R4, 2026-08-28).
|
||||||
|
#
|
||||||
|
# issue-edit already uses long-flag-first parsing (-i/--issue, -t/--title,
|
||||||
|
# -b/--body, -l/--labels, -m/--milestone); this adds the rc=2 usage-error
|
||||||
|
# contract, value checks, and the no-provider-contact proof. Required: -i.
|
||||||
|
#
|
||||||
|
# Arms:
|
||||||
|
# 1. --help and -h exit 0 and print usage.
|
||||||
|
# 2. Unknown option exits 2 with the message on stderr.
|
||||||
|
# 3. Missing required -i exits 2 (stderr).
|
||||||
|
# 4. A value-less flag (-i -b -c and long forms) exits 2 (stderr).
|
||||||
|
# 5. -b and -c both pass parsing (sandboxed runner: the run then fails
|
||||||
|
# at credential resolution, nonzero and NOT 2) — no real token is
|
||||||
|
# ever read and no provider is contacted.
|
||||||
|
# 6. No arm performs any provider request (PATH shims record every
|
||||||
|
# invocation; the probe log must stay empty).
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/issue-assign-usage}"
|
||||||
|
BIN_DIR="$WORK_DIR/bin"
|
||||||
|
PROBE_LOG="$WORK_DIR/provider-probes.log"
|
||||||
|
OUT_FILE="$WORK_DIR/out.log"
|
||||||
|
ERR_FILE="$WORK_DIR/err.log"
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
rm -rf "$WORK_DIR"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
mkdir -p "$BIN_DIR"
|
||||||
|
: > "$PROBE_LOG"
|
||||||
|
|
||||||
|
# Unlike the issue suites, these stubs FAIL (exit 99): pr-close has an
|
||||||
|
# API fallback that treats a successful curl as a closed PR, so exit-0
|
||||||
|
# stubs would let the sandbox arms "succeed" (measured 2026-08-28).
|
||||||
|
for tool in gh tea curl; do
|
||||||
|
cat > "$BIN_DIR/$tool" <<STUB
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
echo "$tool \$*" >> "$PROBE_LOG"
|
||||||
|
exit 99
|
||||||
|
STUB
|
||||||
|
chmod +x "$BIN_DIR/$tool"
|
||||||
|
done
|
||||||
|
|
||||||
|
run_wrapper() {
|
||||||
|
( cd "$WORK_DIR" && PATH="$BIN_DIR:$PATH" "$SCRIPT_DIR/issue-assign.sh" "$@" )
|
||||||
|
}
|
||||||
|
|
||||||
|
# Hermetic variant: neutralizes every identity/credential source the wrapper
|
||||||
|
# consults so parse-acceptance arms fail at credential resolution in ANY cwd
|
||||||
|
# repo (see test-issue-comment-usage-contract.sh for the measured incident).
|
||||||
|
run_wrapper_sandboxed() {
|
||||||
|
mkdir -p "$WORK_DIR/home" "$WORK_DIR/xdg"
|
||||||
|
(
|
||||||
|
cd "$WORK_DIR"
|
||||||
|
PATH="$BIN_DIR:$PATH" HOME="$WORK_DIR/home" XDG_CONFIG_HOME="$WORK_DIR/xdg" \
|
||||||
|
MOSAIC_GIT_IDENTITY="" MOSAIC_BRAIN_HOME="" \
|
||||||
|
"$SCRIPT_DIR/issue-assign.sh" "$@"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
echo "FAIL: $*" >&2
|
||||||
|
echo "--- stderr ---" >&2
|
||||||
|
cat "$ERR_FILE" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
expect_rc() { # expect_rc <want> <desc> <args...>
|
||||||
|
local want="$1" desc="$2" rc=0
|
||||||
|
shift 2
|
||||||
|
run_wrapper "$@" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
|
||||||
|
[[ "$rc" -eq "$want" ]] || fail "$desc: rc=$rc, want $want"
|
||||||
|
}
|
||||||
|
|
||||||
|
expect_stderr() { # expect_stderr <pattern> <desc>
|
||||||
|
grep -q "$1" "$ERR_FILE" || fail "$2: stderr missing '$1'"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 1. Help exits 0 and prints usage.
|
||||||
|
expect_rc 0 "--help exits 0" --help
|
||||||
|
grep -q "Usage: issue-assign.sh" "$OUT_FILE" || fail "--help did not print usage"
|
||||||
|
expect_rc 0 "-h exits 0" -h
|
||||||
|
|
||||||
|
# 2. Unknown option: rc 2, stderr.
|
||||||
|
expect_rc 2 "unknown option exits 2" --bogus
|
||||||
|
expect_stderr "[Uu]nknown option" "unknown option names itself on stderr"
|
||||||
|
|
||||||
|
# 3. Missing required PR number: rc 2, stderr.
|
||||||
|
expect_rc 2 "missing -i exits 2"
|
||||||
|
expect_stderr "Issue number is required" "missing -i message on stderr"
|
||||||
|
|
||||||
|
|
||||||
|
# 4. Value-less flags: rc 2 with "requires a value" on stderr.
|
||||||
|
for flag in -i -a -l -m --issue --assignee --labels --milestone; do
|
||||||
|
expect_rc 2 "value-less $flag exits 2" "$flag"
|
||||||
|
expect_stderr "requires a value" "value-less $flag message on stderr"
|
||||||
|
done
|
||||||
|
|
||||||
|
# 4a. An option-like value is a MISSING value, not a value (codex PR #1464:
|
||||||
|
# -b --help previously consumed --help as the body and performed the write).
|
||||||
|
expect_rc 2 "option-like value rejected" -i 5 -a --help
|
||||||
|
expect_rc 2 "short flag value rejected" -i 5 -a -h
|
||||||
|
expect_stderr "requires a value" "short flag value message on stderr"
|
||||||
|
expect_stderr "requires a value" "option-like value message on stderr"
|
||||||
|
|
||||||
|
# 4b. Parser-failure arms (1-4) must have performed ZERO provider contact.
|
||||||
|
if [[ -s "$PROBE_LOG" ]]; then
|
||||||
|
echo "FAIL: a parser-failure arm contacted a provider:" >&2
|
||||||
|
cat "$PROBE_LOG" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 5. Alias acceptance under the sandbox: both -b and -c carry a value past
|
||||||
|
# parsing; the run fails at credential resolution nonzero and NOT 2.
|
||||||
|
for flag in -a; do
|
||||||
|
rc=0
|
||||||
|
run_wrapper_sandboxed -i 5 "$flag" "value" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
|
||||||
|
[[ "$rc" -ne 0 ]] || fail "$flag arm unexpectedly succeeded in the sandbox"
|
||||||
|
[[ "$rc" -ne 2 ]] || fail "$flag arm misclassified credential failure as a usage error"
|
||||||
|
done
|
||||||
|
|
||||||
|
# 6. Post-sandbox provider assertions are intentionally NOT applied here:
|
||||||
|
# pr-close's gitea path attempts a tea WRITE (tea pr comment) when a
|
||||||
|
# comment parses, then falls back to the API. Hermeticity for this
|
||||||
|
# wrapper comes from the FAILING stubs (exit 99), not from non-contact —
|
||||||
|
# the arm above proves only parse acceptance and non-usage classification.
|
||||||
|
# Parser-failure arms (1-4) remain zero-contact (asserted at 4b).
|
||||||
|
|
||||||
|
# 6b. Provider-exit normalization (codex PR #1464): a provider stub exiting
|
||||||
|
# 2 (its own usage-error status) must surface as wrapper exit 1, never 2.
|
||||||
|
GH_REPO="$WORK_DIR/repo-gh"
|
||||||
|
mkdir -p "$GH_REPO"
|
||||||
|
git -C "$GH_REPO" init -q
|
||||||
|
git -C "$GH_REPO" remote add origin https://github.com/acme/widgets.git
|
||||||
|
cat > "$BIN_DIR/gh" <<GHSTUB
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
echo "gh \$*" >> "$PROBE_LOG"
|
||||||
|
if [[ "\$1 \$2" == "issue edit" ]]; then exit 2; fi
|
||||||
|
exit 0
|
||||||
|
GHSTUB
|
||||||
|
chmod +x "$BIN_DIR/gh"
|
||||||
|
rc=0
|
||||||
|
(
|
||||||
|
cd "$GH_REPO"
|
||||||
|
PATH="$BIN_DIR:$PATH" MOSAIC_GIT_IDENTITY="" MOSAIC_BRAIN_HOME="" \
|
||||||
|
"$SCRIPT_DIR/issue-assign.sh" -i 5 -a someone >"$OUT_FILE" 2>"$ERR_FILE"
|
||||||
|
) || rc=$?
|
||||||
|
[[ "$rc" -eq 1 ]] || fail "GitHub path: gh exit 2 must normalize to wrapper exit 1 (got $rc)"
|
||||||
|
grep -q "provider" "$ERR_FILE" || fail "GitHub path: normalized provider error missing from stderr"
|
||||||
|
|
||||||
|
echo "issue-assign.sh usage-contract regression passed (R1/R4)"
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Usage-error contract for issue-close.sh (R1/R4, 2026-08-28).
|
||||||
|
#
|
||||||
|
# R4: usage errors print to STDERR and exit 2, distinct from provider,
|
||||||
|
# credential, and verification failures (exit 1). R1: -b/--body is the
|
||||||
|
# canonical comment flag; -c/--comment remains a compatible alias.
|
||||||
|
# The comment is OPTIONAL here (an issue may close without one), so unlike
|
||||||
|
# issue-comment there is no missing-comment arm.
|
||||||
|
#
|
||||||
|
# Arms:
|
||||||
|
# 1. --help and -h exit 0 and print usage.
|
||||||
|
# 2. Unknown option exits 2 with the message on stderr.
|
||||||
|
# 3. Missing required -i exits 2 (stderr).
|
||||||
|
# 4. A value-less flag (-i -b -c and long forms) exits 2 (stderr).
|
||||||
|
# 5. -b and -c both pass parsing (sandboxed runner: the run then fails
|
||||||
|
# at credential resolution, nonzero and NOT 2) — no real token is
|
||||||
|
# ever read and no provider is contacted.
|
||||||
|
# 6. No arm performs any provider request (PATH shims record every
|
||||||
|
# invocation; the probe log must stay empty).
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/issue-close-usage}"
|
||||||
|
BIN_DIR="$WORK_DIR/bin"
|
||||||
|
PROBE_LOG="$WORK_DIR/provider-probes.log"
|
||||||
|
OUT_FILE="$WORK_DIR/out.log"
|
||||||
|
ERR_FILE="$WORK_DIR/err.log"
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
rm -rf "$WORK_DIR"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
mkdir -p "$BIN_DIR"
|
||||||
|
: > "$PROBE_LOG"
|
||||||
|
|
||||||
|
for tool in gh tea curl; do
|
||||||
|
cat > "$BIN_DIR/$tool" <<STUB
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
echo "$tool \$*" >> "$PROBE_LOG"
|
||||||
|
exit 0
|
||||||
|
STUB
|
||||||
|
chmod +x "$BIN_DIR/$tool"
|
||||||
|
done
|
||||||
|
|
||||||
|
run_wrapper() {
|
||||||
|
( cd "$WORK_DIR" && PATH="$BIN_DIR:$PATH" "$SCRIPT_DIR/issue-close.sh" "$@" )
|
||||||
|
}
|
||||||
|
|
||||||
|
# Hermetic variant: neutralizes every identity/credential source the wrapper
|
||||||
|
# consults so parse-acceptance arms fail at credential resolution in ANY cwd
|
||||||
|
# repo (see test-issue-comment-usage-contract.sh for the measured incident).
|
||||||
|
run_wrapper_sandboxed() {
|
||||||
|
mkdir -p "$WORK_DIR/home" "$WORK_DIR/xdg"
|
||||||
|
(
|
||||||
|
cd "$WORK_DIR"
|
||||||
|
PATH="$BIN_DIR:$PATH" HOME="$WORK_DIR/home" XDG_CONFIG_HOME="$WORK_DIR/xdg" \
|
||||||
|
MOSAIC_GIT_IDENTITY="" MOSAIC_BRAIN_HOME="" \
|
||||||
|
"$SCRIPT_DIR/issue-close.sh" "$@"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
echo "FAIL: $*" >&2
|
||||||
|
echo "--- stderr ---" >&2
|
||||||
|
cat "$ERR_FILE" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
expect_rc() { # expect_rc <want> <desc> <args...>
|
||||||
|
local want="$1" desc="$2" rc=0
|
||||||
|
shift 2
|
||||||
|
run_wrapper "$@" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
|
||||||
|
[[ "$rc" -eq "$want" ]] || fail "$desc: rc=$rc, want $want"
|
||||||
|
}
|
||||||
|
|
||||||
|
expect_stderr() { # expect_stderr <pattern> <desc>
|
||||||
|
grep -q "$1" "$ERR_FILE" || fail "$2: stderr missing '$1'"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 1. Help exits 0 and prints usage.
|
||||||
|
expect_rc 0 "--help exits 0" --help
|
||||||
|
grep -q "Usage: issue-close.sh" "$OUT_FILE" || fail "--help did not print usage"
|
||||||
|
expect_rc 0 "-h exits 0" -h
|
||||||
|
|
||||||
|
# 2. Unknown option: rc 2, stderr.
|
||||||
|
expect_rc 2 "unknown option exits 2" --bogus
|
||||||
|
expect_stderr "unknown option" "unknown option names itself on stderr"
|
||||||
|
|
||||||
|
# 3. Missing required issue number: rc 2, stderr.
|
||||||
|
expect_rc 2 "missing -i exits 2"
|
||||||
|
expect_stderr "issue number is required" "missing -i message on stderr"
|
||||||
|
|
||||||
|
# 4. Value-less flags: rc 2 with "requires a value" on stderr.
|
||||||
|
for flag in -i -b -c --issue --body --comment; do
|
||||||
|
expect_rc 2 "value-less $flag exits 2" "$flag"
|
||||||
|
expect_stderr "requires a value" "value-less $flag message on stderr"
|
||||||
|
done
|
||||||
|
|
||||||
|
# 4a. An option-like value is a MISSING value, not a value (codex PR #1464:
|
||||||
|
# -b --help previously consumed --help as the body and performed the write).
|
||||||
|
expect_rc 2 "option-like value rejected" -i 5 -b --help
|
||||||
|
expect_rc 2 "short flag value rejected" -i 5 -b -h
|
||||||
|
expect_stderr "requires a value" "short flag value message on stderr"
|
||||||
|
expect_stderr "requires a value" "option-like value message on stderr"
|
||||||
|
|
||||||
|
# 4b. Parser-failure arms (1-4) must have performed ZERO provider contact.
|
||||||
|
if [[ -s "$PROBE_LOG" ]]; then
|
||||||
|
echo "FAIL: a parser-failure arm contacted a provider:" >&2
|
||||||
|
cat "$PROBE_LOG" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 5. Alias acceptance under the sandbox: both -b and -c carry a value past
|
||||||
|
# parsing; the run fails at credential resolution nonzero and NOT 2.
|
||||||
|
for flag in -b -c; do
|
||||||
|
rc=0
|
||||||
|
run_wrapper_sandboxed -i 5 "$flag" "closing note" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
|
||||||
|
[[ "$rc" -ne 0 ]] || fail "$flag arm unexpectedly succeeded in the sandbox"
|
||||||
|
[[ "$rc" -ne 2 ]] || fail "$flag arm misclassified credential failure as a usage error"
|
||||||
|
done
|
||||||
|
|
||||||
|
# 6. Sandbox arms may issue DETECTION reads only (tea login list via the
|
||||||
|
# stub); no gh/curl write or read may occur.
|
||||||
|
if grep -Ev '^(gh|tea|curl) login list' "$PROBE_LOG" | grep -q .; then
|
||||||
|
echo "FAIL: a sandbox arm performed a non-detection provider request:" >&2
|
||||||
|
grep -Ev '^(gh|tea|curl) login list' "$PROBE_LOG" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if grep -qE '^(gh|curl)' "$PROBE_LOG"; then
|
||||||
|
echo "FAIL: gh or curl was invoked during a sandbox arm:" >&2
|
||||||
|
grep -E '^(gh|curl)' "$PROBE_LOG" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "issue-close.sh usage-contract regression passed (R1/R4)"
|
||||||
@@ -97,7 +97,7 @@ expect_rc() { # expect_rc <want> <desc> <args...>
|
|||||||
}
|
}
|
||||||
|
|
||||||
expect_stderr() { # expect_stderr <pattern> <desc>
|
expect_stderr() { # expect_stderr <pattern> <desc>
|
||||||
grep -q "$1" "$ERR_FILE" || fail "$desc: stderr missing '$1'"
|
grep -q "$1" "$ERR_FILE" || fail "$2: stderr missing '$1'"
|
||||||
}
|
}
|
||||||
|
|
||||||
# 1. Help exits 0 and prints usage on stdout.
|
# 1. Help exits 0 and prints usage on stdout.
|
||||||
@@ -124,6 +124,13 @@ for flag in -i -b -c -l --issue --body --comment --login; do
|
|||||||
expect_stderr "requires a value" "value-less $flag message on stderr"
|
expect_stderr "requires a value" "value-less $flag message on stderr"
|
||||||
done
|
done
|
||||||
|
|
||||||
|
# 4a. An option-like value is a MISSING value, not a value (codex PR #1464:
|
||||||
|
# -b --help previously consumed --help as the body and performed the write).
|
||||||
|
expect_rc 2 "option-like value rejected" -i 5 -b --help
|
||||||
|
expect_rc 2 "short flag value rejected" -i 5 -b -h
|
||||||
|
expect_stderr "requires a value" "short flag value message on stderr"
|
||||||
|
expect_stderr "requires a value" "option-like value message on stderr"
|
||||||
|
|
||||||
# 6. Alias acceptance at parse level: both -b and -c carry a value past
|
# 6. Alias acceptance at parse level: both -b and -c carry a value past
|
||||||
# parsing; the wrapper then fails at platform detection (not a git repo)
|
# parsing; the wrapper then fails at platform detection (not a git repo)
|
||||||
# nonzero but NOT as a usage error (rc must not be 2).
|
# nonzero but NOT as a usage error (rc must not be 2).
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Usage-error contract for issue-create.sh (R4, 2026-08-28).
|
||||||
|
#
|
||||||
|
# issue-edit already uses long-flag-first parsing (-i/--issue, -t/--title,
|
||||||
|
# -b/--body, -l/--labels, -m/--milestone); this adds the rc=2 usage-error
|
||||||
|
# contract, value checks, and the no-provider-contact proof. Required: -i.
|
||||||
|
#
|
||||||
|
# Arms:
|
||||||
|
# 1. --help and -h exit 0 and print usage.
|
||||||
|
# 2. Unknown option exits 2 with the message on stderr.
|
||||||
|
# 3. Missing required -i exits 2 (stderr).
|
||||||
|
# 4. A value-less flag (-i -b -c and long forms) exits 2 (stderr).
|
||||||
|
# 5. -b and -c both pass parsing (sandboxed runner: the run then fails
|
||||||
|
# at credential resolution, nonzero and NOT 2) — no real token is
|
||||||
|
# ever read and no provider is contacted.
|
||||||
|
# 6. No arm performs any provider request (PATH shims record every
|
||||||
|
# invocation; the probe log must stay empty).
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/issue-create-usage}"
|
||||||
|
BIN_DIR="$WORK_DIR/bin"
|
||||||
|
PROBE_LOG="$WORK_DIR/provider-probes.log"
|
||||||
|
OUT_FILE="$WORK_DIR/out.log"
|
||||||
|
ERR_FILE="$WORK_DIR/err.log"
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
rm -rf "$WORK_DIR"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
mkdir -p "$BIN_DIR"
|
||||||
|
: > "$PROBE_LOG"
|
||||||
|
|
||||||
|
# Unlike the issue suites, these stubs FAIL (exit 99): pr-close has an
|
||||||
|
# API fallback that treats a successful curl as a closed PR, so exit-0
|
||||||
|
# stubs would let the sandbox arms "succeed" (measured 2026-08-28).
|
||||||
|
for tool in gh tea curl; do
|
||||||
|
cat > "$BIN_DIR/$tool" <<STUB
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
echo "$tool \$*" >> "$PROBE_LOG"
|
||||||
|
exit 99
|
||||||
|
STUB
|
||||||
|
chmod +x "$BIN_DIR/$tool"
|
||||||
|
done
|
||||||
|
|
||||||
|
run_wrapper() {
|
||||||
|
( cd "$WORK_DIR" && PATH="$BIN_DIR:$PATH" "$SCRIPT_DIR/issue-create.sh" "$@" )
|
||||||
|
}
|
||||||
|
|
||||||
|
# Hermetic variant: neutralizes every identity/credential source the wrapper
|
||||||
|
# consults so parse-acceptance arms fail at credential resolution in ANY cwd
|
||||||
|
# repo (see test-issue-comment-usage-contract.sh for the measured incident).
|
||||||
|
run_wrapper_sandboxed() {
|
||||||
|
mkdir -p "$WORK_DIR/home" "$WORK_DIR/xdg"
|
||||||
|
(
|
||||||
|
cd "$WORK_DIR"
|
||||||
|
PATH="$BIN_DIR:$PATH" HOME="$WORK_DIR/home" XDG_CONFIG_HOME="$WORK_DIR/xdg" \
|
||||||
|
MOSAIC_GIT_IDENTITY="" MOSAIC_BRAIN_HOME="" \
|
||||||
|
"$SCRIPT_DIR/issue-create.sh" "$@"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
echo "FAIL: $*" >&2
|
||||||
|
echo "--- stderr ---" >&2
|
||||||
|
cat "$ERR_FILE" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
expect_rc() { # expect_rc <want> <desc> <args...>
|
||||||
|
local want="$1" desc="$2" rc=0
|
||||||
|
shift 2
|
||||||
|
run_wrapper "$@" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
|
||||||
|
[[ "$rc" -eq "$want" ]] || fail "$desc: rc=$rc, want $want"
|
||||||
|
}
|
||||||
|
|
||||||
|
expect_stderr() { # expect_stderr <pattern> <desc>
|
||||||
|
grep -q "$1" "$ERR_FILE" || fail "$2: stderr missing '$1'"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 1. Help exits 0 and prints usage.
|
||||||
|
expect_rc 0 "--help exits 0" --help
|
||||||
|
grep -q "Usage: issue-create.sh" "$OUT_FILE" || fail "--help did not print usage"
|
||||||
|
expect_rc 0 "-h exits 0" -h
|
||||||
|
|
||||||
|
# 2. Unknown option: rc 2, stderr.
|
||||||
|
expect_rc 2 "unknown option exits 2" --bogus
|
||||||
|
expect_stderr "[Uu]nknown option" "unknown option names itself on stderr"
|
||||||
|
|
||||||
|
# 3. Missing required PR number: rc 2, stderr.
|
||||||
|
expect_rc 2 "missing -t exits 2"
|
||||||
|
expect_stderr "Title is required" "missing -t message on stderr"
|
||||||
|
|
||||||
|
|
||||||
|
# 4. Value-less flags: rc 2 with "requires a value" on stderr.
|
||||||
|
for flag in -t -b -l -m --title --body --labels --milestone; do
|
||||||
|
expect_rc 2 "value-less $flag exits 2" "$flag"
|
||||||
|
expect_stderr "requires a value" "value-less $flag message on stderr"
|
||||||
|
done
|
||||||
|
|
||||||
|
# 4a. An option-like value is a MISSING value, not a value (codex PR #1464:
|
||||||
|
# -b --help previously consumed --help as the body and performed the write).
|
||||||
|
expect_rc 2 "option-like value rejected" -t smoke -b --help
|
||||||
|
expect_rc 2 "short flag value rejected" -t smoke -b -h
|
||||||
|
expect_stderr "requires a value" "short flag value message on stderr"
|
||||||
|
expect_stderr "requires a value" "option-like value message on stderr"
|
||||||
|
|
||||||
|
# 4b. Parser-failure arms (1-4) must have performed ZERO provider contact.
|
||||||
|
if [[ -s "$PROBE_LOG" ]]; then
|
||||||
|
echo "FAIL: a parser-failure arm contacted a provider:" >&2
|
||||||
|
cat "$PROBE_LOG" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 5. Alias acceptance under the sandbox: both -b and -c carry a value past
|
||||||
|
# parsing; the run fails at credential resolution nonzero and NOT 2.
|
||||||
|
for flag in -b; do
|
||||||
|
rc=0
|
||||||
|
run_wrapper_sandboxed -t "smoke" "$flag" "value" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
|
||||||
|
[[ "$rc" -ne 0 ]] || fail "$flag arm unexpectedly succeeded in the sandbox"
|
||||||
|
[[ "$rc" -ne 2 ]] || fail "$flag arm misclassified credential failure as a usage error"
|
||||||
|
done
|
||||||
|
|
||||||
|
# 6. Post-sandbox provider assertions are intentionally NOT applied here:
|
||||||
|
# pr-close's gitea path attempts a tea WRITE (tea pr comment) when a
|
||||||
|
# comment parses, then falls back to the API. Hermeticity for this
|
||||||
|
# wrapper comes from the FAILING stubs (exit 99), not from non-contact —
|
||||||
|
# the arm above proves only parse acceptance and non-usage classification.
|
||||||
|
# Parser-failure arms (1-4) remain zero-contact (asserted at 4b).
|
||||||
|
|
||||||
|
echo "issue-create.sh usage-contract regression passed (R1/R4)"
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Usage-error contract for issue-edit.sh (R4, 2026-08-28).
|
||||||
|
#
|
||||||
|
# issue-edit already uses long-flag-first parsing (-i/--issue, -t/--title,
|
||||||
|
# -b/--body, -l/--labels, -m/--milestone); this adds the rc=2 usage-error
|
||||||
|
# contract, value checks, and the no-provider-contact proof. Required: -i.
|
||||||
|
#
|
||||||
|
# Arms:
|
||||||
|
# 1. --help and -h exit 0 and print usage.
|
||||||
|
# 2. Unknown option exits 2 with the message on stderr.
|
||||||
|
# 3. Missing required -i exits 2 (stderr).
|
||||||
|
# 4. A value-less flag (-i -b -c and long forms) exits 2 (stderr).
|
||||||
|
# 5. -b and -c both pass parsing (sandboxed runner: the run then fails
|
||||||
|
# at credential resolution, nonzero and NOT 2) — no real token is
|
||||||
|
# ever read and no provider is contacted.
|
||||||
|
# 6. No arm performs any provider request (PATH shims record every
|
||||||
|
# invocation; the probe log must stay empty).
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/issue-edit-usage}"
|
||||||
|
BIN_DIR="$WORK_DIR/bin"
|
||||||
|
PROBE_LOG="$WORK_DIR/provider-probes.log"
|
||||||
|
OUT_FILE="$WORK_DIR/out.log"
|
||||||
|
ERR_FILE="$WORK_DIR/err.log"
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
rm -rf "$WORK_DIR"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
mkdir -p "$BIN_DIR"
|
||||||
|
: > "$PROBE_LOG"
|
||||||
|
|
||||||
|
# Unlike the issue suites, these stubs FAIL (exit 99): pr-close has an
|
||||||
|
# API fallback that treats a successful curl as a closed PR, so exit-0
|
||||||
|
# stubs would let the sandbox arms "succeed" (measured 2026-08-28).
|
||||||
|
for tool in gh tea curl; do
|
||||||
|
cat > "$BIN_DIR/$tool" <<STUB
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
echo "$tool \$*" >> "$PROBE_LOG"
|
||||||
|
exit 99
|
||||||
|
STUB
|
||||||
|
chmod +x "$BIN_DIR/$tool"
|
||||||
|
done
|
||||||
|
|
||||||
|
run_wrapper() {
|
||||||
|
( cd "$WORK_DIR" && PATH="$BIN_DIR:$PATH" "$SCRIPT_DIR/issue-edit.sh" "$@" )
|
||||||
|
}
|
||||||
|
|
||||||
|
# Hermetic variant: neutralizes every identity/credential source the wrapper
|
||||||
|
# consults so parse-acceptance arms fail at credential resolution in ANY cwd
|
||||||
|
# repo (see test-issue-comment-usage-contract.sh for the measured incident).
|
||||||
|
run_wrapper_sandboxed() {
|
||||||
|
mkdir -p "$WORK_DIR/home" "$WORK_DIR/xdg"
|
||||||
|
(
|
||||||
|
cd "$WORK_DIR"
|
||||||
|
PATH="$BIN_DIR:$PATH" HOME="$WORK_DIR/home" XDG_CONFIG_HOME="$WORK_DIR/xdg" \
|
||||||
|
MOSAIC_GIT_IDENTITY="" MOSAIC_BRAIN_HOME="" \
|
||||||
|
"$SCRIPT_DIR/issue-edit.sh" "$@"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
echo "FAIL: $*" >&2
|
||||||
|
echo "--- stderr ---" >&2
|
||||||
|
cat "$ERR_FILE" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
expect_rc() { # expect_rc <want> <desc> <args...>
|
||||||
|
local want="$1" desc="$2" rc=0
|
||||||
|
shift 2
|
||||||
|
run_wrapper "$@" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
|
||||||
|
[[ "$rc" -eq "$want" ]] || fail "$desc: rc=$rc, want $want"
|
||||||
|
}
|
||||||
|
|
||||||
|
expect_stderr() { # expect_stderr <pattern> <desc>
|
||||||
|
grep -q "$1" "$ERR_FILE" || fail "$2: stderr missing '$1'"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 1. Help exits 0 and prints usage.
|
||||||
|
expect_rc 0 "--help exits 0" --help
|
||||||
|
grep -q "Usage: issue-edit.sh" "$OUT_FILE" || fail "--help did not print usage"
|
||||||
|
expect_rc 0 "-h exits 0" -h
|
||||||
|
|
||||||
|
# 2. Unknown option: rc 2, stderr.
|
||||||
|
expect_rc 2 "unknown option exits 2" --bogus
|
||||||
|
expect_stderr "unknown option" "unknown option names itself on stderr"
|
||||||
|
|
||||||
|
# 3. Missing required PR number: rc 2, stderr.
|
||||||
|
expect_rc 2 "missing -i exits 2"
|
||||||
|
expect_stderr "issue number is required" "missing -i message on stderr"
|
||||||
|
|
||||||
|
|
||||||
|
# 4. Value-less flags: rc 2 with "requires a value" on stderr.
|
||||||
|
for flag in -i -t -b -l -m --issue --title --body --labels --milestone; do
|
||||||
|
expect_rc 2 "value-less $flag exits 2" "$flag"
|
||||||
|
expect_stderr "requires a value" "value-less $flag message on stderr"
|
||||||
|
done
|
||||||
|
|
||||||
|
# 4a. An option-like value is a MISSING value, not a value (codex PR #1464:
|
||||||
|
# -b --help previously consumed --help as the body and performed the write).
|
||||||
|
expect_rc 2 "option-like value rejected" -i 5 -b --help
|
||||||
|
expect_rc 2 "short flag value rejected" -i 5 -b -h
|
||||||
|
expect_stderr "requires a value" "short flag value message on stderr"
|
||||||
|
expect_stderr "requires a value" "option-like value message on stderr"
|
||||||
|
|
||||||
|
# 4b. Parser-failure arms (1-4) must have performed ZERO provider contact.
|
||||||
|
if [[ -s "$PROBE_LOG" ]]; then
|
||||||
|
echo "FAIL: a parser-failure arm contacted a provider:" >&2
|
||||||
|
cat "$PROBE_LOG" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 5. Alias acceptance under the sandbox: both -b and -c carry a value past
|
||||||
|
# parsing; the run fails at credential resolution nonzero and NOT 2.
|
||||||
|
for flag in -b; do
|
||||||
|
rc=0
|
||||||
|
run_wrapper_sandboxed -i 5 "$flag" "value" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
|
||||||
|
[[ "$rc" -ne 0 ]] || fail "$flag arm unexpectedly succeeded in the sandbox"
|
||||||
|
[[ "$rc" -ne 2 ]] || fail "$flag arm misclassified credential failure as a usage error"
|
||||||
|
done
|
||||||
|
|
||||||
|
# 6. Post-sandbox provider assertions are intentionally NOT applied here:
|
||||||
|
# pr-close's gitea path attempts a tea WRITE (tea pr comment) when a
|
||||||
|
# comment parses, then falls back to the API. Hermeticity for this
|
||||||
|
# wrapper comes from the FAILING stubs (exit 99), not from non-contact —
|
||||||
|
# the arm above proves only parse acceptance and non-usage classification.
|
||||||
|
# Parser-failure arms (1-4) remain zero-contact (asserted at 4b).
|
||||||
|
|
||||||
|
echo "issue-edit.sh usage-contract regression passed (R1/R4)"
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Usage-error contract for issue-list.sh (R4, 2026-08-28).
|
||||||
|
#
|
||||||
|
# issue-edit already uses long-flag-first parsing (-i/--issue, -t/--title,
|
||||||
|
# -b/--body, -l/--labels, -m/--milestone); this adds the rc=2 usage-error
|
||||||
|
# contract, value checks, and the no-provider-contact proof. Required: -i.
|
||||||
|
#
|
||||||
|
# Arms:
|
||||||
|
# 1. --help and -h exit 0 and print usage.
|
||||||
|
# 2. Unknown option exits 2 with the message on stderr.
|
||||||
|
# 3. Missing required -i exits 2 (stderr).
|
||||||
|
# 4. A value-less flag (-i -b -c and long forms) exits 2 (stderr).
|
||||||
|
# 5. -b and -c both pass parsing (sandboxed runner: the run then fails
|
||||||
|
# at credential resolution, nonzero and NOT 2) — no real token is
|
||||||
|
# ever read and no provider is contacted.
|
||||||
|
# 6. No arm performs any provider request (PATH shims record every
|
||||||
|
# invocation; the probe log must stay empty).
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/issue-list-usage}"
|
||||||
|
BIN_DIR="$WORK_DIR/bin"
|
||||||
|
PROBE_LOG="$WORK_DIR/provider-probes.log"
|
||||||
|
OUT_FILE="$WORK_DIR/out.log"
|
||||||
|
ERR_FILE="$WORK_DIR/err.log"
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
rm -rf "$WORK_DIR"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
mkdir -p "$BIN_DIR"
|
||||||
|
: > "$PROBE_LOG"
|
||||||
|
|
||||||
|
# Unlike the issue suites, these stubs FAIL (exit 99): pr-close has an
|
||||||
|
# API fallback that treats a successful curl as a closed PR, so exit-0
|
||||||
|
# stubs would let the sandbox arms "succeed" (measured 2026-08-28).
|
||||||
|
for tool in gh tea curl; do
|
||||||
|
cat > "$BIN_DIR/$tool" <<STUB
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
echo "$tool \$*" >> "$PROBE_LOG"
|
||||||
|
exit 99
|
||||||
|
STUB
|
||||||
|
chmod +x "$BIN_DIR/$tool"
|
||||||
|
done
|
||||||
|
|
||||||
|
run_wrapper() {
|
||||||
|
( cd "$WORK_DIR" && PATH="$BIN_DIR:$PATH" "$SCRIPT_DIR/issue-list.sh" "$@" )
|
||||||
|
}
|
||||||
|
|
||||||
|
# Hermetic variant: neutralizes every identity/credential source the wrapper
|
||||||
|
# consults so parse-acceptance arms fail at credential resolution in ANY cwd
|
||||||
|
# repo (see test-issue-comment-usage-contract.sh for the measured incident).
|
||||||
|
run_wrapper_sandboxed() {
|
||||||
|
mkdir -p "$WORK_DIR/home" "$WORK_DIR/xdg"
|
||||||
|
(
|
||||||
|
cd "$WORK_DIR"
|
||||||
|
PATH="$BIN_DIR:$PATH" HOME="$WORK_DIR/home" XDG_CONFIG_HOME="$WORK_DIR/xdg" \
|
||||||
|
MOSAIC_GIT_IDENTITY="" MOSAIC_BRAIN_HOME="" \
|
||||||
|
"$SCRIPT_DIR/issue-list.sh" "$@"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
echo "FAIL: $*" >&2
|
||||||
|
echo "--- stderr ---" >&2
|
||||||
|
cat "$ERR_FILE" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
expect_rc() { # expect_rc <want> <desc> <args...>
|
||||||
|
local want="$1" desc="$2" rc=0
|
||||||
|
shift 2
|
||||||
|
run_wrapper "$@" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
|
||||||
|
[[ "$rc" -eq "$want" ]] || fail "$desc: rc=$rc, want $want"
|
||||||
|
}
|
||||||
|
|
||||||
|
expect_stderr() { # expect_stderr <pattern> <desc>
|
||||||
|
grep -q "$1" "$ERR_FILE" || fail "$2: stderr missing '$1'"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 1. Help exits 0 and prints usage.
|
||||||
|
expect_rc 0 "--help exits 0" --help
|
||||||
|
grep -q "Usage: issue-list.sh" "$OUT_FILE" || fail "--help did not print usage"
|
||||||
|
expect_rc 0 "-h exits 0" -h
|
||||||
|
|
||||||
|
# 2. Unknown option: rc 2, stderr.
|
||||||
|
expect_rc 2 "unknown option exits 2" --bogus
|
||||||
|
expect_stderr "[Uu]nknown option" "unknown option names itself on stderr"
|
||||||
|
|
||||||
|
# 3. Missing required PR number: rc 2, stderr.
|
||||||
|
|
||||||
|
|
||||||
|
# 4. Value-less flags: rc 2 with "requires a value" on stderr.
|
||||||
|
for flag in -s -l -m -a -n -r --state --label --milestone --assignee --limit --repo; do
|
||||||
|
expect_rc 2 "value-less $flag exits 2" "$flag"
|
||||||
|
expect_stderr "requires a value" "value-less $flag message on stderr"
|
||||||
|
done
|
||||||
|
|
||||||
|
# 4a. An option-like value is a MISSING value, not a value (codex PR #1464:
|
||||||
|
# -b --help previously consumed --help as the body and performed the write).
|
||||||
|
expect_rc 2 "option-like value rejected" -s --help
|
||||||
|
expect_rc 2 "short flag value rejected" -s -h
|
||||||
|
expect_stderr "requires a value" "short flag value message on stderr"
|
||||||
|
expect_stderr "requires a value" "option-like value message on stderr"
|
||||||
|
|
||||||
|
# 4b. Parser-failure arms (1-4) must have performed ZERO provider contact.
|
||||||
|
if [[ -s "$PROBE_LOG" ]]; then
|
||||||
|
echo "FAIL: a parser-failure arm contacted a provider:" >&2
|
||||||
|
cat "$PROBE_LOG" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 5. Alias acceptance under the sandbox: both -b and -c carry a value past
|
||||||
|
# parsing; the run fails at credential resolution nonzero and NOT 2.
|
||||||
|
for flag in -s; do
|
||||||
|
rc=0
|
||||||
|
run_wrapper_sandboxed -s open >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
|
||||||
|
[[ "$rc" -ne 0 ]] || fail "$flag arm unexpectedly succeeded in the sandbox"
|
||||||
|
[[ "$rc" -ne 2 ]] || fail "$flag arm misclassified credential failure as a usage error"
|
||||||
|
done
|
||||||
|
|
||||||
|
# 6. Post-sandbox provider assertions are intentionally NOT applied here:
|
||||||
|
# pr-close's gitea path attempts a tea WRITE (tea pr comment) when a
|
||||||
|
# comment parses, then falls back to the API. Hermeticity for this
|
||||||
|
# wrapper comes from the FAILING stubs (exit 99), not from non-contact —
|
||||||
|
# the arm above proves only parse acceptance and non-usage classification.
|
||||||
|
# Parser-failure arms (1-4) remain zero-contact (asserted at 4b).
|
||||||
|
|
||||||
|
echo "issue-list.sh usage-contract regression passed (R1/R4)"
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Usage-error contract for issue-reopen.sh (R1/R4, 2026-08-28).
|
||||||
|
#
|
||||||
|
# R4: usage errors print to STDERR and exit 2, distinct from provider,
|
||||||
|
# credential, and verification failures (exit 1). R1: -b/--body is the
|
||||||
|
# canonical comment flag; -c/--comment remains a compatible alias.
|
||||||
|
# The comment is OPTIONAL here (an issue may close without one), so unlike
|
||||||
|
# issue-comment there is no missing-comment arm.
|
||||||
|
#
|
||||||
|
# Arms:
|
||||||
|
# 1. --help and -h exit 0 and print usage.
|
||||||
|
# 2. Unknown option exits 2 with the message on stderr.
|
||||||
|
# 3. Missing required -i exits 2 (stderr).
|
||||||
|
# 4. A value-less flag (-i -b -c and long forms) exits 2 (stderr).
|
||||||
|
# 5. -b and -c both pass parsing (sandboxed runner: the run then fails
|
||||||
|
# at credential resolution, nonzero and NOT 2) — no real token is
|
||||||
|
# ever read and no provider is contacted.
|
||||||
|
# 6. No arm performs any provider request (PATH shims record every
|
||||||
|
# invocation; the probe log must stay empty).
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/issue-reopen-usage}"
|
||||||
|
BIN_DIR="$WORK_DIR/bin"
|
||||||
|
PROBE_LOG="$WORK_DIR/provider-probes.log"
|
||||||
|
OUT_FILE="$WORK_DIR/out.log"
|
||||||
|
ERR_FILE="$WORK_DIR/err.log"
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
rm -rf "$WORK_DIR"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
mkdir -p "$BIN_DIR"
|
||||||
|
: > "$PROBE_LOG"
|
||||||
|
|
||||||
|
for tool in gh tea curl; do
|
||||||
|
cat > "$BIN_DIR/$tool" <<STUB
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
echo "$tool \$*" >> "$PROBE_LOG"
|
||||||
|
exit 0
|
||||||
|
STUB
|
||||||
|
chmod +x "$BIN_DIR/$tool"
|
||||||
|
done
|
||||||
|
|
||||||
|
run_wrapper() {
|
||||||
|
( cd "$WORK_DIR" && PATH="$BIN_DIR:$PATH" "$SCRIPT_DIR/issue-reopen.sh" "$@" )
|
||||||
|
}
|
||||||
|
|
||||||
|
# Hermetic variant: neutralizes every identity/credential source the wrapper
|
||||||
|
# consults so parse-acceptance arms fail at credential resolution in ANY cwd
|
||||||
|
# repo (see test-issue-comment-usage-contract.sh for the measured incident).
|
||||||
|
run_wrapper_sandboxed() {
|
||||||
|
mkdir -p "$WORK_DIR/home" "$WORK_DIR/xdg"
|
||||||
|
(
|
||||||
|
cd "$WORK_DIR"
|
||||||
|
PATH="$BIN_DIR:$PATH" HOME="$WORK_DIR/home" XDG_CONFIG_HOME="$WORK_DIR/xdg" \
|
||||||
|
MOSAIC_GIT_IDENTITY="" MOSAIC_BRAIN_HOME="" \
|
||||||
|
"$SCRIPT_DIR/issue-reopen.sh" "$@"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
echo "FAIL: $*" >&2
|
||||||
|
echo "--- stderr ---" >&2
|
||||||
|
cat "$ERR_FILE" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
expect_rc() { # expect_rc <want> <desc> <args...>
|
||||||
|
local want="$1" desc="$2" rc=0
|
||||||
|
shift 2
|
||||||
|
run_wrapper "$@" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
|
||||||
|
[[ "$rc" -eq "$want" ]] || fail "$desc: rc=$rc, want $want"
|
||||||
|
}
|
||||||
|
|
||||||
|
expect_stderr() { # expect_stderr <pattern> <desc>
|
||||||
|
grep -q "$1" "$ERR_FILE" || fail "$2: stderr missing '$1'"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 1. Help exits 0 and prints usage.
|
||||||
|
expect_rc 0 "--help exits 0" --help
|
||||||
|
grep -q "Usage: issue-reopen.sh" "$OUT_FILE" || fail "--help did not print usage"
|
||||||
|
expect_rc 0 "-h exits 0" -h
|
||||||
|
|
||||||
|
# 2. Unknown option: rc 2, stderr.
|
||||||
|
expect_rc 2 "unknown option exits 2" --bogus
|
||||||
|
expect_stderr "unknown option" "unknown option names itself on stderr"
|
||||||
|
|
||||||
|
# 3. Missing required issue number: rc 2, stderr.
|
||||||
|
expect_rc 2 "missing -i exits 2"
|
||||||
|
expect_stderr "issue number is required" "missing -i message on stderr"
|
||||||
|
|
||||||
|
# 4. Value-less flags: rc 2 with "requires a value" on stderr.
|
||||||
|
for flag in -i -b -c --issue --body --comment; do
|
||||||
|
expect_rc 2 "value-less $flag exits 2" "$flag"
|
||||||
|
expect_stderr "requires a value" "value-less $flag message on stderr"
|
||||||
|
done
|
||||||
|
|
||||||
|
# 4a. An option-like value is a MISSING value, not a value (codex PR #1464:
|
||||||
|
# -b --help previously consumed --help as the body and performed the write).
|
||||||
|
expect_rc 2 "option-like value rejected" -i 5 -b --help
|
||||||
|
expect_rc 2 "short flag value rejected" -i 5 -b -h
|
||||||
|
expect_stderr "requires a value" "short flag value message on stderr"
|
||||||
|
expect_stderr "requires a value" "option-like value message on stderr"
|
||||||
|
|
||||||
|
# 4b. Parser-failure arms (1-4) must have performed ZERO provider contact.
|
||||||
|
if [[ -s "$PROBE_LOG" ]]; then
|
||||||
|
echo "FAIL: a parser-failure arm contacted a provider:" >&2
|
||||||
|
cat "$PROBE_LOG" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 5. Alias acceptance under the sandbox: both -b and -c carry a value past
|
||||||
|
# parsing; the run fails at credential resolution nonzero and NOT 2.
|
||||||
|
for flag in -b -c; do
|
||||||
|
rc=0
|
||||||
|
run_wrapper_sandboxed -i 5 "$flag" "closing note" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
|
||||||
|
[[ "$rc" -ne 0 ]] || fail "$flag arm unexpectedly succeeded in the sandbox"
|
||||||
|
[[ "$rc" -ne 2 ]] || fail "$flag arm misclassified credential failure as a usage error"
|
||||||
|
done
|
||||||
|
|
||||||
|
# 6. Sandbox arms may issue DETECTION reads only (tea login list via the
|
||||||
|
# stub); no gh/curl write or read may occur.
|
||||||
|
if grep -Ev '^(gh|tea|curl) login list' "$PROBE_LOG" | grep -q .; then
|
||||||
|
echo "FAIL: a sandbox arm performed a non-detection provider request:" >&2
|
||||||
|
grep -Ev '^(gh|tea|curl) login list' "$PROBE_LOG" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if grep -qE '^(gh|curl)' "$PROBE_LOG"; then
|
||||||
|
echo "FAIL: gh or curl was invoked during a sandbox arm:" >&2
|
||||||
|
grep -E '^(gh|curl)' "$PROBE_LOG" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "issue-reopen.sh usage-contract regression passed (R1/R4)"
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Usage-error contract for issue-view.sh (R4, 2026-08-28).
|
||||||
|
#
|
||||||
|
# issue-edit already uses long-flag-first parsing (-i/--issue, -t/--title,
|
||||||
|
# -b/--body, -l/--labels, -m/--milestone); this adds the rc=2 usage-error
|
||||||
|
# contract, value checks, and the no-provider-contact proof. Required: -i.
|
||||||
|
#
|
||||||
|
# Arms:
|
||||||
|
# 1. --help and -h exit 0 and print usage.
|
||||||
|
# 2. Unknown option exits 2 with the message on stderr.
|
||||||
|
# 3. Missing required -i exits 2 (stderr).
|
||||||
|
# 4. A value-less flag (-i -b -c and long forms) exits 2 (stderr).
|
||||||
|
# 5. -b and -c both pass parsing (sandboxed runner: the run then fails
|
||||||
|
# at credential resolution, nonzero and NOT 2) — no real token is
|
||||||
|
# ever read and no provider is contacted.
|
||||||
|
# 6. No arm performs any provider request (PATH shims record every
|
||||||
|
# invocation; the probe log must stay empty).
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/issue-view-usage}"
|
||||||
|
BIN_DIR="$WORK_DIR/bin"
|
||||||
|
PROBE_LOG="$WORK_DIR/provider-probes.log"
|
||||||
|
OUT_FILE="$WORK_DIR/out.log"
|
||||||
|
ERR_FILE="$WORK_DIR/err.log"
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
rm -rf "$WORK_DIR"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
mkdir -p "$BIN_DIR"
|
||||||
|
: > "$PROBE_LOG"
|
||||||
|
|
||||||
|
# Unlike the issue suites, these stubs FAIL (exit 99): pr-close has an
|
||||||
|
# API fallback that treats a successful curl as a closed PR, so exit-0
|
||||||
|
# stubs would let the sandbox arms "succeed" (measured 2026-08-28).
|
||||||
|
for tool in gh tea curl; do
|
||||||
|
cat > "$BIN_DIR/$tool" <<STUB
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
echo "$tool \$*" >> "$PROBE_LOG"
|
||||||
|
exit 99
|
||||||
|
STUB
|
||||||
|
chmod +x "$BIN_DIR/$tool"
|
||||||
|
done
|
||||||
|
|
||||||
|
run_wrapper() {
|
||||||
|
( cd "$WORK_DIR" && PATH="$BIN_DIR:$PATH" "$SCRIPT_DIR/issue-view.sh" "$@" )
|
||||||
|
}
|
||||||
|
|
||||||
|
# Hermetic variant: neutralizes every identity/credential source the wrapper
|
||||||
|
# consults so parse-acceptance arms fail at credential resolution in ANY cwd
|
||||||
|
# repo (see test-issue-comment-usage-contract.sh for the measured incident).
|
||||||
|
run_wrapper_sandboxed() {
|
||||||
|
mkdir -p "$WORK_DIR/home" "$WORK_DIR/xdg"
|
||||||
|
(
|
||||||
|
cd "$WORK_DIR"
|
||||||
|
PATH="$BIN_DIR:$PATH" HOME="$WORK_DIR/home" XDG_CONFIG_HOME="$WORK_DIR/xdg" \
|
||||||
|
MOSAIC_GIT_IDENTITY="" MOSAIC_BRAIN_HOME="" \
|
||||||
|
"$SCRIPT_DIR/issue-view.sh" "$@"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
echo "FAIL: $*" >&2
|
||||||
|
echo "--- stderr ---" >&2
|
||||||
|
cat "$ERR_FILE" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
expect_rc() { # expect_rc <want> <desc> <args...>
|
||||||
|
local want="$1" desc="$2" rc=0
|
||||||
|
shift 2
|
||||||
|
run_wrapper "$@" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
|
||||||
|
[[ "$rc" -eq "$want" ]] || fail "$desc: rc=$rc, want $want"
|
||||||
|
}
|
||||||
|
|
||||||
|
expect_stderr() { # expect_stderr <pattern> <desc>
|
||||||
|
grep -q "$1" "$ERR_FILE" || fail "$2: stderr missing '$1'"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 1. Help exits 0 and prints usage.
|
||||||
|
expect_rc 0 "--help exits 0" --help
|
||||||
|
grep -q "Usage: issue-view.sh" "$OUT_FILE" || fail "--help did not print usage"
|
||||||
|
expect_rc 0 "-h exits 0" -h
|
||||||
|
|
||||||
|
# 2. Unknown option: rc 2, stderr.
|
||||||
|
expect_rc 2 "unknown option exits 2" --bogus
|
||||||
|
expect_stderr "[Uu]nknown option" "unknown option names itself on stderr"
|
||||||
|
|
||||||
|
# 3. Missing required PR number: rc 2, stderr.
|
||||||
|
expect_rc 2 "missing -i exits 2"
|
||||||
|
expect_stderr "Issue number is required" "missing -i message on stderr"
|
||||||
|
|
||||||
|
|
||||||
|
# 4. Value-less flags: rc 2 with "requires a value" on stderr.
|
||||||
|
for flag in -i --issue; do
|
||||||
|
expect_rc 2 "value-less $flag exits 2" "$flag"
|
||||||
|
expect_stderr "requires a value" "value-less $flag message on stderr"
|
||||||
|
done
|
||||||
|
|
||||||
|
# 4a. An option-like value is a MISSING value, not a value (codex PR #1464:
|
||||||
|
# -b --help previously consumed --help as the body and performed the write).
|
||||||
|
expect_rc 2 "option-like value rejected" -i --help
|
||||||
|
expect_rc 2 "short flag value rejected" -i -h
|
||||||
|
expect_stderr "requires a value" "short flag value message on stderr"
|
||||||
|
expect_stderr "requires a value" "option-like value message on stderr"
|
||||||
|
|
||||||
|
# 4b. Parser-failure arms (1-4) must have performed ZERO provider contact.
|
||||||
|
if [[ -s "$PROBE_LOG" ]]; then
|
||||||
|
echo "FAIL: a parser-failure arm contacted a provider:" >&2
|
||||||
|
cat "$PROBE_LOG" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 5. Alias acceptance under the sandbox: both -b and -c carry a value past
|
||||||
|
# parsing; the run fails at credential resolution nonzero and NOT 2.
|
||||||
|
for flag in -i; do
|
||||||
|
rc=0
|
||||||
|
run_wrapper_sandboxed -i 5 >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
|
||||||
|
[[ "$rc" -ne 0 ]] || fail "$flag arm unexpectedly succeeded in the sandbox"
|
||||||
|
[[ "$rc" -ne 2 ]] || fail "$flag arm misclassified credential failure as a usage error"
|
||||||
|
done
|
||||||
|
|
||||||
|
# 6. Post-sandbox provider assertions are intentionally NOT applied here:
|
||||||
|
# pr-close's gitea path attempts a tea WRITE (tea pr comment) when a
|
||||||
|
# comment parses, then falls back to the API. Hermeticity for this
|
||||||
|
# wrapper comes from the FAILING stubs (exit 99), not from non-contact —
|
||||||
|
# the arm above proves only parse acceptance and non-usage classification.
|
||||||
|
# Parser-failure arms (1-4) remain zero-contact (asserted at 4b).
|
||||||
|
|
||||||
|
echo "issue-view.sh usage-contract regression passed (R1/R4)"
|
||||||
Regular → Executable
@@ -0,0 +1,132 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Usage-error contract for lane-brief.sh (R4, 2026-08-28).
|
||||||
|
#
|
||||||
|
# issue-edit already uses long-flag-first parsing (-i/--issue, -t/--title,
|
||||||
|
# -b/--body, -l/--labels, -m/--milestone); this adds the rc=2 usage-error
|
||||||
|
# contract, value checks, and the no-provider-contact proof. Required: -i.
|
||||||
|
#
|
||||||
|
# Arms:
|
||||||
|
# 1. --help and -h exit 0 and print usage.
|
||||||
|
# 2. Unknown option exits 2 with the message on stderr.
|
||||||
|
# 3. Missing required -i exits 2 (stderr).
|
||||||
|
# 4. A value-less flag (-i -b -c and long forms) exits 2 (stderr).
|
||||||
|
# 5. -b and -c both pass parsing (sandboxed runner: the run then fails
|
||||||
|
# at credential resolution, nonzero and NOT 2) — no real token is
|
||||||
|
# ever read and no provider is contacted.
|
||||||
|
# 6. No arm performs any provider request (PATH shims record every
|
||||||
|
# invocation; the probe log must stay empty).
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/lane-brief-usage}"
|
||||||
|
BIN_DIR="$WORK_DIR/bin"
|
||||||
|
PROBE_LOG="$WORK_DIR/provider-probes.log"
|
||||||
|
OUT_FILE="$WORK_DIR/out.log"
|
||||||
|
ERR_FILE="$WORK_DIR/err.log"
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
rm -rf "$WORK_DIR"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
mkdir -p "$BIN_DIR"
|
||||||
|
: > "$PROBE_LOG"
|
||||||
|
|
||||||
|
# Unlike the issue suites, these stubs FAIL (exit 99): pr-close has an
|
||||||
|
# API fallback that treats a successful curl as a closed PR, so exit-0
|
||||||
|
# stubs would let the sandbox arms "succeed" (measured 2026-08-28).
|
||||||
|
for tool in gh tea curl; do
|
||||||
|
cat > "$BIN_DIR/$tool" <<STUB
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
echo "$tool \$*" >> "$PROBE_LOG"
|
||||||
|
exit 99
|
||||||
|
STUB
|
||||||
|
chmod +x "$BIN_DIR/$tool"
|
||||||
|
done
|
||||||
|
|
||||||
|
run_wrapper() {
|
||||||
|
( cd "$WORK_DIR" && PATH="$BIN_DIR:$PATH" "$SCRIPT_DIR/lane-brief.sh" "$@" )
|
||||||
|
}
|
||||||
|
|
||||||
|
# Hermetic variant: neutralizes every identity/credential source the wrapper
|
||||||
|
# consults so parse-acceptance arms fail at credential resolution in ANY cwd
|
||||||
|
# repo (see test-issue-comment-usage-contract.sh for the measured incident).
|
||||||
|
run_wrapper_sandboxed() {
|
||||||
|
mkdir -p "$WORK_DIR/home" "$WORK_DIR/xdg"
|
||||||
|
(
|
||||||
|
cd "$WORK_DIR"
|
||||||
|
PATH="$BIN_DIR:$PATH" HOME="$WORK_DIR/home" XDG_CONFIG_HOME="$WORK_DIR/xdg" \
|
||||||
|
MOSAIC_GIT_IDENTITY="" MOSAIC_BRAIN_HOME="" \
|
||||||
|
"$SCRIPT_DIR/lane-brief.sh" "$@"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
echo "FAIL: $*" >&2
|
||||||
|
echo "--- stderr ---" >&2
|
||||||
|
cat "$ERR_FILE" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
expect_rc() { # expect_rc <want> <desc> <args...>
|
||||||
|
local want="$1" desc="$2" rc=0
|
||||||
|
shift 2
|
||||||
|
run_wrapper "$@" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
|
||||||
|
[[ "$rc" -eq "$want" ]] || fail "$desc: rc=$rc, want $want"
|
||||||
|
}
|
||||||
|
|
||||||
|
expect_stderr() { # expect_stderr <pattern> <desc>
|
||||||
|
grep -q "$1" "$ERR_FILE" || fail "$2: stderr missing '$1'"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 1. Help exits 0 and prints usage.
|
||||||
|
expect_rc 0 "--help exits 0" --help
|
||||||
|
grep -q "owner/repo" "$OUT_FILE" || fail "--help did not print usage"
|
||||||
|
expect_rc 0 "-h exits 0" -h
|
||||||
|
|
||||||
|
# 2. Unknown option: rc 2, stderr.
|
||||||
|
expect_rc 2 "unknown option exits 2" --bogus
|
||||||
|
expect_stderr "[Uu]nknown option" "unknown option names itself on stderr"
|
||||||
|
|
||||||
|
# 3. Missing required PR number: rc 2, stderr.
|
||||||
|
expect_rc 2 "missing -r exits 2"
|
||||||
|
expect_stderr "required" "missing -r message on stderr"
|
||||||
|
|
||||||
|
|
||||||
|
# 4. Value-less flags: rc 2 with "requires a value" on stderr.
|
||||||
|
for flag in -r -m -l -L -n --repo --milestone --label --login --limit; do
|
||||||
|
expect_rc 2 "value-less $flag exits 2" "$flag"
|
||||||
|
expect_stderr "requires a value" "value-less $flag message on stderr"
|
||||||
|
done
|
||||||
|
|
||||||
|
# 4a. An option-like value is a MISSING value, not a value (codex PR #1464:
|
||||||
|
# -b --help previously consumed --help as the body and performed the write).
|
||||||
|
expect_rc 2 "option-like value rejected" -r owner/repo -m --help
|
||||||
|
expect_rc 2 "short flag value rejected" -r owner/repo -m -h
|
||||||
|
expect_stderr "requires a value" "short flag value message on stderr"
|
||||||
|
expect_stderr "requires a value" "option-like value message on stderr"
|
||||||
|
|
||||||
|
# 4b. Parser-failure arms (1-4) must have performed ZERO provider contact.
|
||||||
|
if [[ -s "$PROBE_LOG" ]]; then
|
||||||
|
echo "FAIL: a parser-failure arm contacted a provider:" >&2
|
||||||
|
cat "$PROBE_LOG" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 5. Alias acceptance under the sandbox: both -b and -c carry a value past
|
||||||
|
# parsing; the run fails at credential resolution nonzero and NOT 2.
|
||||||
|
for flag in -r; do
|
||||||
|
rc=0
|
||||||
|
run_wrapper_sandboxed -r owner/repo >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
|
||||||
|
[[ "$rc" -ne 0 ]] || fail "$flag arm unexpectedly succeeded in the sandbox"
|
||||||
|
[[ "$rc" -ne 2 ]] || fail "$flag arm misclassified credential failure as a usage error"
|
||||||
|
done
|
||||||
|
|
||||||
|
# 6. Post-sandbox provider assertions are intentionally NOT applied here:
|
||||||
|
# pr-close's gitea path attempts a tea WRITE (tea pr comment) when a
|
||||||
|
# comment parses, then falls back to the API. Hermeticity for this
|
||||||
|
# wrapper comes from the FAILING stubs (exit 99), not from non-contact —
|
||||||
|
# the arm above proves only parse acceptance and non-usage classification.
|
||||||
|
# Parser-failure arms (1-4) remain zero-contact (asserted at 4b).
|
||||||
|
|
||||||
|
echo "lane-brief.sh usage-contract regression passed (R1/R4)"
|
||||||
+132
@@ -0,0 +1,132 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Usage-error contract for milestone-close.sh (R4, 2026-08-28).
|
||||||
|
#
|
||||||
|
# issue-edit already uses long-flag-first parsing (-i/--issue, -t/--title,
|
||||||
|
# -b/--body, -l/--labels, -m/--milestone); this adds the rc=2 usage-error
|
||||||
|
# contract, value checks, and the no-provider-contact proof. Required: -i.
|
||||||
|
#
|
||||||
|
# Arms:
|
||||||
|
# 1. --help and -h exit 0 and print usage.
|
||||||
|
# 2. Unknown option exits 2 with the message on stderr.
|
||||||
|
# 3. Missing required -i exits 2 (stderr).
|
||||||
|
# 4. A value-less flag (-i -b -c and long forms) exits 2 (stderr).
|
||||||
|
# 5. -b and -c both pass parsing (sandboxed runner: the run then fails
|
||||||
|
# at credential resolution, nonzero and NOT 2) — no real token is
|
||||||
|
# ever read and no provider is contacted.
|
||||||
|
# 6. No arm performs any provider request (PATH shims record every
|
||||||
|
# invocation; the probe log must stay empty).
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/milestone-close-usage}"
|
||||||
|
BIN_DIR="$WORK_DIR/bin"
|
||||||
|
PROBE_LOG="$WORK_DIR/provider-probes.log"
|
||||||
|
OUT_FILE="$WORK_DIR/out.log"
|
||||||
|
ERR_FILE="$WORK_DIR/err.log"
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
rm -rf "$WORK_DIR"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
mkdir -p "$BIN_DIR"
|
||||||
|
: > "$PROBE_LOG"
|
||||||
|
|
||||||
|
# Unlike the issue suites, these stubs FAIL (exit 99): pr-close has an
|
||||||
|
# API fallback that treats a successful curl as a closed PR, so exit-0
|
||||||
|
# stubs would let the sandbox arms "succeed" (measured 2026-08-28).
|
||||||
|
for tool in gh tea curl; do
|
||||||
|
cat > "$BIN_DIR/$tool" <<STUB
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
echo "$tool \$*" >> "$PROBE_LOG"
|
||||||
|
exit 99
|
||||||
|
STUB
|
||||||
|
chmod +x "$BIN_DIR/$tool"
|
||||||
|
done
|
||||||
|
|
||||||
|
run_wrapper() {
|
||||||
|
( cd "$WORK_DIR" && PATH="$BIN_DIR:$PATH" "$SCRIPT_DIR/milestone-close.sh" "$@" )
|
||||||
|
}
|
||||||
|
|
||||||
|
# Hermetic variant: neutralizes every identity/credential source the wrapper
|
||||||
|
# consults so parse-acceptance arms fail at credential resolution in ANY cwd
|
||||||
|
# repo (see test-issue-comment-usage-contract.sh for the measured incident).
|
||||||
|
run_wrapper_sandboxed() {
|
||||||
|
mkdir -p "$WORK_DIR/home" "$WORK_DIR/xdg"
|
||||||
|
(
|
||||||
|
cd "$WORK_DIR"
|
||||||
|
PATH="$BIN_DIR:$PATH" HOME="$WORK_DIR/home" XDG_CONFIG_HOME="$WORK_DIR/xdg" \
|
||||||
|
MOSAIC_GIT_IDENTITY="" MOSAIC_BRAIN_HOME="" \
|
||||||
|
"$SCRIPT_DIR/milestone-close.sh" "$@"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
echo "FAIL: $*" >&2
|
||||||
|
echo "--- stderr ---" >&2
|
||||||
|
cat "$ERR_FILE" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
expect_rc() { # expect_rc <want> <desc> <args...>
|
||||||
|
local want="$1" desc="$2" rc=0
|
||||||
|
shift 2
|
||||||
|
run_wrapper "$@" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
|
||||||
|
[[ "$rc" -eq "$want" ]] || fail "$desc: rc=$rc, want $want"
|
||||||
|
}
|
||||||
|
|
||||||
|
expect_stderr() { # expect_stderr <pattern> <desc>
|
||||||
|
grep -q "$1" "$ERR_FILE" || fail "$2: stderr missing '$1'"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 1. Help exits 0 and prints usage.
|
||||||
|
expect_rc 0 "--help exits 0" --help
|
||||||
|
grep -q "Usage: milestone-close.sh" "$OUT_FILE" || fail "--help did not print usage"
|
||||||
|
expect_rc 0 "-h exits 0" -h
|
||||||
|
|
||||||
|
# 2. Unknown option: rc 2, stderr.
|
||||||
|
expect_rc 2 "unknown option exits 2" --bogus
|
||||||
|
expect_stderr "[Uu]nknown option" "unknown option names itself on stderr"
|
||||||
|
|
||||||
|
# 3. Missing required PR number: rc 2, stderr.
|
||||||
|
expect_rc 2 "missing -t exits 2"
|
||||||
|
expect_stderr "Milestone title is required" "missing -t message on stderr"
|
||||||
|
|
||||||
|
|
||||||
|
# 4. Value-less flags: rc 2 with "requires a value" on stderr.
|
||||||
|
for flag in -t --title; do
|
||||||
|
expect_rc 2 "value-less $flag exits 2" "$flag"
|
||||||
|
expect_stderr "requires a value" "value-less $flag message on stderr"
|
||||||
|
done
|
||||||
|
|
||||||
|
# 4a. An option-like value is a MISSING value, not a value (codex PR #1464:
|
||||||
|
# -b --help previously consumed --help as the body and performed the write).
|
||||||
|
expect_rc 2 "option-like value rejected" -t --help --help
|
||||||
|
expect_rc 2 "short flag value rejected" -t --help -h
|
||||||
|
expect_stderr "requires a value" "short flag value message on stderr"
|
||||||
|
expect_stderr "requires a value" "option-like value message on stderr"
|
||||||
|
|
||||||
|
# 4b. Parser-failure arms (1-4) must have performed ZERO provider contact.
|
||||||
|
if [[ -s "$PROBE_LOG" ]]; then
|
||||||
|
echo "FAIL: a parser-failure arm contacted a provider:" >&2
|
||||||
|
cat "$PROBE_LOG" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 5. Alias acceptance under the sandbox: both -b and -c carry a value past
|
||||||
|
# parsing; the run fails at credential resolution nonzero and NOT 2.
|
||||||
|
for flag in -t; do
|
||||||
|
rc=0
|
||||||
|
run_wrapper_sandboxed -t "smoke" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
|
||||||
|
[[ "$rc" -ne 0 ]] || fail "$flag arm unexpectedly succeeded in the sandbox"
|
||||||
|
[[ "$rc" -ne 2 ]] || fail "$flag arm misclassified credential failure as a usage error"
|
||||||
|
done
|
||||||
|
|
||||||
|
# 6. Post-sandbox provider assertions are intentionally NOT applied here:
|
||||||
|
# pr-close's gitea path attempts a tea WRITE (tea pr comment) when a
|
||||||
|
# comment parses, then falls back to the API. Hermeticity for this
|
||||||
|
# wrapper comes from the FAILING stubs (exit 99), not from non-contact —
|
||||||
|
# the arm above proves only parse acceptance and non-usage classification.
|
||||||
|
# Parser-failure arms (1-4) remain zero-contact (asserted at 4b).
|
||||||
|
|
||||||
|
echo "milestone-close.sh usage-contract regression passed (R1/R4)"
|
||||||
+132
@@ -0,0 +1,132 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Usage-error contract for milestone-create.sh (R4, 2026-08-28).
|
||||||
|
#
|
||||||
|
# issue-edit already uses long-flag-first parsing (-i/--issue, -t/--title,
|
||||||
|
# -b/--body, -l/--labels, -m/--milestone); this adds the rc=2 usage-error
|
||||||
|
# contract, value checks, and the no-provider-contact proof. Required: -i.
|
||||||
|
#
|
||||||
|
# Arms:
|
||||||
|
# 1. --help and -h exit 0 and print usage.
|
||||||
|
# 2. Unknown option exits 2 with the message on stderr.
|
||||||
|
# 3. Missing required -i exits 2 (stderr).
|
||||||
|
# 4. A value-less flag (-i -b -c and long forms) exits 2 (stderr).
|
||||||
|
# 5. -b and -c both pass parsing (sandboxed runner: the run then fails
|
||||||
|
# at credential resolution, nonzero and NOT 2) — no real token is
|
||||||
|
# ever read and no provider is contacted.
|
||||||
|
# 6. No arm performs any provider request (PATH shims record every
|
||||||
|
# invocation; the probe log must stay empty).
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/milestone-create-usage}"
|
||||||
|
BIN_DIR="$WORK_DIR/bin"
|
||||||
|
PROBE_LOG="$WORK_DIR/provider-probes.log"
|
||||||
|
OUT_FILE="$WORK_DIR/out.log"
|
||||||
|
ERR_FILE="$WORK_DIR/err.log"
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
rm -rf "$WORK_DIR"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
mkdir -p "$BIN_DIR"
|
||||||
|
: > "$PROBE_LOG"
|
||||||
|
|
||||||
|
# Unlike the issue suites, these stubs FAIL (exit 99): pr-close has an
|
||||||
|
# API fallback that treats a successful curl as a closed PR, so exit-0
|
||||||
|
# stubs would let the sandbox arms "succeed" (measured 2026-08-28).
|
||||||
|
for tool in gh tea curl; do
|
||||||
|
cat > "$BIN_DIR/$tool" <<STUB
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
echo "$tool \$*" >> "$PROBE_LOG"
|
||||||
|
exit 99
|
||||||
|
STUB
|
||||||
|
chmod +x "$BIN_DIR/$tool"
|
||||||
|
done
|
||||||
|
|
||||||
|
run_wrapper() {
|
||||||
|
( cd "$WORK_DIR" && PATH="$BIN_DIR:$PATH" "$SCRIPT_DIR/milestone-create.sh" "$@" )
|
||||||
|
}
|
||||||
|
|
||||||
|
# Hermetic variant: neutralizes every identity/credential source the wrapper
|
||||||
|
# consults so parse-acceptance arms fail at credential resolution in ANY cwd
|
||||||
|
# repo (see test-issue-comment-usage-contract.sh for the measured incident).
|
||||||
|
run_wrapper_sandboxed() {
|
||||||
|
mkdir -p "$WORK_DIR/home" "$WORK_DIR/xdg"
|
||||||
|
(
|
||||||
|
cd "$WORK_DIR"
|
||||||
|
PATH="$BIN_DIR:$PATH" HOME="$WORK_DIR/home" XDG_CONFIG_HOME="$WORK_DIR/xdg" \
|
||||||
|
MOSAIC_GIT_IDENTITY="" MOSAIC_BRAIN_HOME="" \
|
||||||
|
"$SCRIPT_DIR/milestone-create.sh" "$@"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
echo "FAIL: $*" >&2
|
||||||
|
echo "--- stderr ---" >&2
|
||||||
|
cat "$ERR_FILE" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
expect_rc() { # expect_rc <want> <desc> <args...>
|
||||||
|
local want="$1" desc="$2" rc=0
|
||||||
|
shift 2
|
||||||
|
run_wrapper "$@" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
|
||||||
|
[[ "$rc" -eq "$want" ]] || fail "$desc: rc=$rc, want $want"
|
||||||
|
}
|
||||||
|
|
||||||
|
expect_stderr() { # expect_stderr <pattern> <desc>
|
||||||
|
grep -q "$1" "$ERR_FILE" || fail "$2: stderr missing '$1'"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 1. Help exits 0 and prints usage.
|
||||||
|
expect_rc 0 "--help exits 0" --help
|
||||||
|
grep -q "Usage: milestone-create.sh" "$OUT_FILE" || fail "--help did not print usage"
|
||||||
|
expect_rc 0 "-h exits 0" -h
|
||||||
|
|
||||||
|
# 2. Unknown option: rc 2, stderr.
|
||||||
|
expect_rc 2 "unknown option exits 2" --bogus
|
||||||
|
expect_stderr "[Uu]nknown option" "unknown option names itself on stderr"
|
||||||
|
|
||||||
|
# 3. Missing required PR number: rc 2, stderr.
|
||||||
|
expect_rc 2 "missing -t exits 2"
|
||||||
|
expect_stderr "Title is required" "missing -t message on stderr"
|
||||||
|
|
||||||
|
|
||||||
|
# 4. Value-less flags: rc 2 with "requires a value" on stderr.
|
||||||
|
for flag in -t -d --due --title --desc; do
|
||||||
|
expect_rc 2 "value-less $flag exits 2" "$flag"
|
||||||
|
expect_stderr "requires a value" "value-less $flag message on stderr"
|
||||||
|
done
|
||||||
|
|
||||||
|
# 4a. An option-like value is a MISSING value, not a value (codex PR #1464:
|
||||||
|
# -b --help previously consumed --help as the body and performed the write).
|
||||||
|
expect_rc 2 "option-like value rejected" -t smoke -d --help
|
||||||
|
expect_rc 2 "short flag value rejected" -t smoke -d -h
|
||||||
|
expect_stderr "requires a value" "short flag value message on stderr"
|
||||||
|
expect_stderr "requires a value" "option-like value message on stderr"
|
||||||
|
|
||||||
|
# 4b. Parser-failure arms (1-4) must have performed ZERO provider contact.
|
||||||
|
if [[ -s "$PROBE_LOG" ]]; then
|
||||||
|
echo "FAIL: a parser-failure arm contacted a provider:" >&2
|
||||||
|
cat "$PROBE_LOG" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 5. Alias acceptance under the sandbox: both -b and -c carry a value past
|
||||||
|
# parsing; the run fails at credential resolution nonzero and NOT 2.
|
||||||
|
for flag in -t; do
|
||||||
|
rc=0
|
||||||
|
run_wrapper_sandboxed -t "smoke" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
|
||||||
|
[[ "$rc" -ne 0 ]] || fail "$flag arm unexpectedly succeeded in the sandbox"
|
||||||
|
[[ "$rc" -ne 2 ]] || fail "$flag arm misclassified credential failure as a usage error"
|
||||||
|
done
|
||||||
|
|
||||||
|
# 6. Post-sandbox provider assertions are intentionally NOT applied here:
|
||||||
|
# pr-close's gitea path attempts a tea WRITE (tea pr comment) when a
|
||||||
|
# comment parses, then falls back to the API. Hermeticity for this
|
||||||
|
# wrapper comes from the FAILING stubs (exit 99), not from non-contact —
|
||||||
|
# the arm above proves only parse acceptance and non-usage classification.
|
||||||
|
# Parser-failure arms (1-4) remain zero-contact (asserted at 4b).
|
||||||
|
|
||||||
|
echo "milestone-create.sh usage-contract regression passed (R1/R4)"
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Usage-error contract for milestone-list.sh (R4, 2026-08-28).
|
||||||
|
#
|
||||||
|
# issue-edit already uses long-flag-first parsing (-i/--issue, -t/--title,
|
||||||
|
# -b/--body, -l/--labels, -m/--milestone); this adds the rc=2 usage-error
|
||||||
|
# contract, value checks, and the no-provider-contact proof. Required: -i.
|
||||||
|
#
|
||||||
|
# Arms:
|
||||||
|
# 1. --help and -h exit 0 and print usage.
|
||||||
|
# 2. Unknown option exits 2 with the message on stderr.
|
||||||
|
# 3. Missing required -i exits 2 (stderr).
|
||||||
|
# 4. A value-less flag (-i -b -c and long forms) exits 2 (stderr).
|
||||||
|
# 5. -b and -c both pass parsing (sandboxed runner: the run then fails
|
||||||
|
# at credential resolution, nonzero and NOT 2) — no real token is
|
||||||
|
# ever read and no provider is contacted.
|
||||||
|
# 6. No arm performs any provider request (PATH shims record every
|
||||||
|
# invocation; the probe log must stay empty).
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/milestone-list-usage}"
|
||||||
|
BIN_DIR="$WORK_DIR/bin"
|
||||||
|
PROBE_LOG="$WORK_DIR/provider-probes.log"
|
||||||
|
OUT_FILE="$WORK_DIR/out.log"
|
||||||
|
ERR_FILE="$WORK_DIR/err.log"
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
rm -rf "$WORK_DIR"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
mkdir -p "$BIN_DIR"
|
||||||
|
: > "$PROBE_LOG"
|
||||||
|
|
||||||
|
# Unlike the issue suites, these stubs FAIL (exit 99): pr-close has an
|
||||||
|
# API fallback that treats a successful curl as a closed PR, so exit-0
|
||||||
|
# stubs would let the sandbox arms "succeed" (measured 2026-08-28).
|
||||||
|
for tool in gh tea curl; do
|
||||||
|
cat > "$BIN_DIR/$tool" <<STUB
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
echo "$tool \$*" >> "$PROBE_LOG"
|
||||||
|
exit 99
|
||||||
|
STUB
|
||||||
|
chmod +x "$BIN_DIR/$tool"
|
||||||
|
done
|
||||||
|
|
||||||
|
run_wrapper() {
|
||||||
|
( cd "$WORK_DIR" && PATH="$BIN_DIR:$PATH" "$SCRIPT_DIR/milestone-list.sh" "$@" )
|
||||||
|
}
|
||||||
|
|
||||||
|
# Hermetic variant: neutralizes every identity/credential source the wrapper
|
||||||
|
# consults so parse-acceptance arms fail at credential resolution in ANY cwd
|
||||||
|
# repo (see test-issue-comment-usage-contract.sh for the measured incident).
|
||||||
|
run_wrapper_sandboxed() {
|
||||||
|
mkdir -p "$WORK_DIR/home" "$WORK_DIR/xdg"
|
||||||
|
(
|
||||||
|
cd "$WORK_DIR"
|
||||||
|
PATH="$BIN_DIR:$PATH" HOME="$WORK_DIR/home" XDG_CONFIG_HOME="$WORK_DIR/xdg" \
|
||||||
|
MOSAIC_GIT_IDENTITY="" MOSAIC_BRAIN_HOME="" \
|
||||||
|
"$SCRIPT_DIR/milestone-list.sh" "$@"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
echo "FAIL: $*" >&2
|
||||||
|
echo "--- stderr ---" >&2
|
||||||
|
cat "$ERR_FILE" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
expect_rc() { # expect_rc <want> <desc> <args...>
|
||||||
|
local want="$1" desc="$2" rc=0
|
||||||
|
shift 2
|
||||||
|
run_wrapper "$@" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
|
||||||
|
[[ "$rc" -eq "$want" ]] || fail "$desc: rc=$rc, want $want"
|
||||||
|
}
|
||||||
|
|
||||||
|
expect_stderr() { # expect_stderr <pattern> <desc>
|
||||||
|
grep -q "$1" "$ERR_FILE" || fail "$2: stderr missing '$1'"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 1. Help exits 0 and prints usage.
|
||||||
|
expect_rc 0 "--help exits 0" --help
|
||||||
|
grep -q "Usage: milestone-list.sh" "$OUT_FILE" || fail "--help did not print usage"
|
||||||
|
expect_rc 0 "-h exits 0" -h
|
||||||
|
|
||||||
|
# 2. Unknown option: rc 2, stderr.
|
||||||
|
expect_rc 2 "unknown option exits 2" --bogus
|
||||||
|
expect_stderr "[Uu]nknown option" "unknown option names itself on stderr"
|
||||||
|
|
||||||
|
# 3. Missing required PR number: rc 2, stderr.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# 4. Value-less flags: rc 2 with "requires a value" on stderr.
|
||||||
|
for flag in -s --state; do
|
||||||
|
expect_rc 2 "value-less $flag exits 2" "$flag"
|
||||||
|
expect_stderr "requires a value" "value-less $flag message on stderr"
|
||||||
|
done
|
||||||
|
|
||||||
|
# 4a. An option-like value is a MISSING value, not a value (codex PR #1464:
|
||||||
|
# -b --help previously consumed --help as the body and performed the write).
|
||||||
|
expect_rc 2 "option-like value rejected" -s --help
|
||||||
|
expect_rc 2 "short flag value rejected" -s -h
|
||||||
|
expect_stderr "requires a value" "short flag value message on stderr"
|
||||||
|
expect_stderr "requires a value" "option-like value message on stderr"
|
||||||
|
|
||||||
|
# 4b. Parser-failure arms (1-4) must have performed ZERO provider contact.
|
||||||
|
if [[ -s "$PROBE_LOG" ]]; then
|
||||||
|
echo "FAIL: a parser-failure arm contacted a provider:" >&2
|
||||||
|
cat "$PROBE_LOG" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 5. Alias acceptance under the sandbox: both -b and -c carry a value past
|
||||||
|
# parsing; the run fails at credential resolution nonzero and NOT 2.
|
||||||
|
for flag in -s; do
|
||||||
|
rc=0
|
||||||
|
run_wrapper_sandboxed -s open >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
|
||||||
|
[[ "$rc" -ne 0 ]] || fail "$flag arm unexpectedly succeeded in the sandbox"
|
||||||
|
[[ "$rc" -ne 2 ]] || fail "$flag arm misclassified credential failure as a usage error"
|
||||||
|
done
|
||||||
|
|
||||||
|
# 6. Post-sandbox provider assertions are intentionally NOT applied here:
|
||||||
|
# pr-close's gitea path attempts a tea WRITE (tea pr comment) when a
|
||||||
|
# comment parses, then falls back to the API. Hermeticity for this
|
||||||
|
# wrapper comes from the FAILING stubs (exit 99), not from non-contact —
|
||||||
|
# the arm above proves only parse acceptance and non-usage classification.
|
||||||
|
# Parser-failure arms (1-4) remain zero-contact (asserted at 4b).
|
||||||
|
|
||||||
|
# 6b. Provider-exit normalization (codex PR #1464): a provider stub exiting
|
||||||
|
# 2 (its own usage-error status) must surface as wrapper exit 1, never 2.
|
||||||
|
GH_REPO="$WORK_DIR/repo-gh"
|
||||||
|
mkdir -p "$GH_REPO"
|
||||||
|
git -C "$GH_REPO" init -q
|
||||||
|
git -C "$GH_REPO" remote add origin https://github.com/acme/widgets.git
|
||||||
|
cat > "$BIN_DIR/gh" <<GHSTUB
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
echo "gh \$*" >> "$PROBE_LOG"
|
||||||
|
if [[ "\$1" == "api" ]]; then exit 2; fi
|
||||||
|
exit 0
|
||||||
|
GHSTUB
|
||||||
|
chmod +x "$BIN_DIR/gh"
|
||||||
|
rc=0
|
||||||
|
(
|
||||||
|
cd "$GH_REPO"
|
||||||
|
PATH="$BIN_DIR:$PATH" MOSAIC_GIT_IDENTITY="" MOSAIC_BRAIN_HOME="" \
|
||||||
|
"$SCRIPT_DIR/milestone-list.sh" >"$OUT_FILE" 2>"$ERR_FILE"
|
||||||
|
) || rc=$?
|
||||||
|
[[ "$rc" -eq 1 ]] || fail "GitHub path: gh exit 2 must normalize to wrapper exit 1 (got $rc)"
|
||||||
|
grep -q "provider" "$ERR_FILE" || fail "GitHub path: normalized provider error missing from stderr"
|
||||||
|
|
||||||
|
echo "milestone-list.sh usage-contract regression passed (R1/R4)"
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Usage-error contract for pr-close.sh (R1/R4, 2026-08-28).
|
||||||
|
#
|
||||||
|
# R4: usage errors print to STDERR and exit 2, distinct from provider,
|
||||||
|
# credential, and verification failures (exit 1). R1: -b/--body is the
|
||||||
|
# canonical comment flag; -c/--comment remains a compatible alias.
|
||||||
|
# The comment is OPTIONAL here (an issue may close without one), so unlike
|
||||||
|
# issue-comment there is no missing-comment arm.
|
||||||
|
#
|
||||||
|
# Arms:
|
||||||
|
# 1. --help and -h exit 0 and print usage.
|
||||||
|
# 2. Unknown option exits 2 with the message on stderr.
|
||||||
|
# 3. Missing required -i exits 2 (stderr).
|
||||||
|
# 4. A value-less flag (-i -b -c and long forms) exits 2 (stderr).
|
||||||
|
# 5. -b and -c both pass parsing (sandboxed runner: the run then fails
|
||||||
|
# at credential resolution, nonzero and NOT 2) — no real token is
|
||||||
|
# ever read and no provider is contacted.
|
||||||
|
# 6. No arm performs any provider request (PATH shims record every
|
||||||
|
# invocation; the probe log must stay empty).
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-close-usage}"
|
||||||
|
BIN_DIR="$WORK_DIR/bin"
|
||||||
|
PROBE_LOG="$WORK_DIR/provider-probes.log"
|
||||||
|
OUT_FILE="$WORK_DIR/out.log"
|
||||||
|
ERR_FILE="$WORK_DIR/err.log"
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
rm -rf "$WORK_DIR"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
mkdir -p "$BIN_DIR"
|
||||||
|
: > "$PROBE_LOG"
|
||||||
|
|
||||||
|
# Unlike the issue suites, these stubs FAIL (exit 99): pr-close has an
|
||||||
|
# API fallback that treats a successful curl as a closed PR, so exit-0
|
||||||
|
# stubs would let the sandbox arms "succeed" (measured 2026-08-28).
|
||||||
|
for tool in gh tea curl; do
|
||||||
|
cat > "$BIN_DIR/$tool" <<STUB
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
echo "$tool \$*" >> "$PROBE_LOG"
|
||||||
|
exit 99
|
||||||
|
STUB
|
||||||
|
chmod +x "$BIN_DIR/$tool"
|
||||||
|
done
|
||||||
|
|
||||||
|
run_wrapper() {
|
||||||
|
( cd "$WORK_DIR" && PATH="$BIN_DIR:$PATH" "$SCRIPT_DIR/pr-close.sh" "$@" )
|
||||||
|
}
|
||||||
|
|
||||||
|
# Hermetic variant: neutralizes every identity/credential source the wrapper
|
||||||
|
# consults so parse-acceptance arms fail at credential resolution in ANY cwd
|
||||||
|
# repo (see test-issue-comment-usage-contract.sh for the measured incident).
|
||||||
|
run_wrapper_sandboxed() {
|
||||||
|
mkdir -p "$WORK_DIR/home" "$WORK_DIR/xdg"
|
||||||
|
(
|
||||||
|
cd "$WORK_DIR"
|
||||||
|
PATH="$BIN_DIR:$PATH" HOME="$WORK_DIR/home" XDG_CONFIG_HOME="$WORK_DIR/xdg" \
|
||||||
|
MOSAIC_GIT_IDENTITY="" MOSAIC_BRAIN_HOME="" \
|
||||||
|
"$SCRIPT_DIR/pr-close.sh" "$@"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
echo "FAIL: $*" >&2
|
||||||
|
echo "--- stderr ---" >&2
|
||||||
|
cat "$ERR_FILE" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
expect_rc() { # expect_rc <want> <desc> <args...>
|
||||||
|
local want="$1" desc="$2" rc=0
|
||||||
|
shift 2
|
||||||
|
run_wrapper "$@" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
|
||||||
|
[[ "$rc" -eq "$want" ]] || fail "$desc: rc=$rc, want $want"
|
||||||
|
}
|
||||||
|
|
||||||
|
expect_stderr() { # expect_stderr <pattern> <desc>
|
||||||
|
grep -q "$1" "$ERR_FILE" || fail "$2: stderr missing '$1'"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 1. Help exits 0 and prints usage.
|
||||||
|
expect_rc 0 "--help exits 0" --help
|
||||||
|
grep -q "Usage: pr-close.sh" "$OUT_FILE" || fail "--help did not print usage"
|
||||||
|
expect_rc 0 "-h exits 0" -h
|
||||||
|
|
||||||
|
# 2. Unknown option: rc 2, stderr.
|
||||||
|
expect_rc 2 "unknown option exits 2" --bogus
|
||||||
|
expect_stderr "unknown option" "unknown option names itself on stderr"
|
||||||
|
|
||||||
|
# 3. Missing required PR number: rc 2, stderr.
|
||||||
|
expect_rc 2 "missing -n exits 2"
|
||||||
|
expect_stderr "PR number is required" "missing -n message on stderr"
|
||||||
|
|
||||||
|
# 4. Value-less flags: rc 2 with "requires a value" on stderr.
|
||||||
|
for flag in -n -b -c --number --body --comment; do
|
||||||
|
expect_rc 2 "value-less $flag exits 2" "$flag"
|
||||||
|
expect_stderr "requires a value" "value-less $flag message on stderr"
|
||||||
|
done
|
||||||
|
|
||||||
|
# 4a. An option-like value is a MISSING value, not a value (codex PR #1464:
|
||||||
|
# -b --help previously consumed --help as the body and performed the write).
|
||||||
|
expect_rc 2 "option-like value rejected" -n 5 -b --help
|
||||||
|
expect_rc 2 "short flag value rejected" -n 5 -b -h
|
||||||
|
expect_stderr "requires a value" "short flag value message on stderr"
|
||||||
|
expect_stderr "requires a value" "option-like value message on stderr"
|
||||||
|
|
||||||
|
# 4b. Parser-failure arms (1-4) must have performed ZERO provider contact.
|
||||||
|
if [[ -s "$PROBE_LOG" ]]; then
|
||||||
|
echo "FAIL: a parser-failure arm contacted a provider:" >&2
|
||||||
|
cat "$PROBE_LOG" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 5. Alias acceptance under the sandbox: both -b and -c carry a value past
|
||||||
|
# parsing; the run fails at credential resolution nonzero and NOT 2.
|
||||||
|
for flag in -b -c; do
|
||||||
|
rc=0
|
||||||
|
run_wrapper_sandboxed -n 5 "$flag" "closing note" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
|
||||||
|
[[ "$rc" -ne 0 ]] || fail "$flag arm unexpectedly succeeded in the sandbox"
|
||||||
|
[[ "$rc" -ne 2 ]] || fail "$flag arm misclassified credential failure as a usage error"
|
||||||
|
done
|
||||||
|
|
||||||
|
# 6. Post-sandbox provider assertions are intentionally NOT applied here:
|
||||||
|
# pr-close's gitea path attempts a tea WRITE (tea pr comment) when a
|
||||||
|
# comment parses, then falls back to the API. Hermeticity for this
|
||||||
|
# wrapper comes from the FAILING stubs (exit 99), not from non-contact —
|
||||||
|
# the arm above proves only parse acceptance and non-usage classification.
|
||||||
|
# Parser-failure arms (1-4) remain zero-contact (asserted at 4b).
|
||||||
|
|
||||||
|
echo "pr-close.sh usage-contract regression passed (R1/R4)"
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Usage-error contract for pr-create.sh (R4, 2026-08-28).
|
||||||
|
#
|
||||||
|
# issue-edit already uses long-flag-first parsing (-i/--issue, -t/--title,
|
||||||
|
# -b/--body, -l/--labels, -m/--milestone); this adds the rc=2 usage-error
|
||||||
|
# contract, value checks, and the no-provider-contact proof. Required: -i.
|
||||||
|
#
|
||||||
|
# Arms:
|
||||||
|
# 1. --help and -h exit 0 and print usage.
|
||||||
|
# 2. Unknown option exits 2 with the message on stderr.
|
||||||
|
# 3. Missing required -i exits 2 (stderr).
|
||||||
|
# 4. A value-less flag (-i -b -c and long forms) exits 2 (stderr).
|
||||||
|
# 5. -b and -c both pass parsing (sandboxed runner: the run then fails
|
||||||
|
# at credential resolution, nonzero and NOT 2) — no real token is
|
||||||
|
# ever read and no provider is contacted.
|
||||||
|
# 6. No arm performs any provider request (PATH shims record every
|
||||||
|
# invocation; the probe log must stay empty).
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-create-usage}"
|
||||||
|
BIN_DIR="$WORK_DIR/bin"
|
||||||
|
PROBE_LOG="$WORK_DIR/provider-probes.log"
|
||||||
|
OUT_FILE="$WORK_DIR/out.log"
|
||||||
|
ERR_FILE="$WORK_DIR/err.log"
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
rm -rf "$WORK_DIR"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
mkdir -p "$BIN_DIR"
|
||||||
|
: > "$PROBE_LOG"
|
||||||
|
|
||||||
|
# Unlike the issue suites, these stubs FAIL (exit 99): pr-close has an
|
||||||
|
# API fallback that treats a successful curl as a closed PR, so exit-0
|
||||||
|
# stubs would let the sandbox arms "succeed" (measured 2026-08-28).
|
||||||
|
for tool in gh tea curl; do
|
||||||
|
cat > "$BIN_DIR/$tool" <<STUB
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
echo "$tool \$*" >> "$PROBE_LOG"
|
||||||
|
exit 99
|
||||||
|
STUB
|
||||||
|
chmod +x "$BIN_DIR/$tool"
|
||||||
|
done
|
||||||
|
|
||||||
|
run_wrapper() {
|
||||||
|
( cd "$WORK_DIR" && PATH="$BIN_DIR:$PATH" "$SCRIPT_DIR/pr-create.sh" "$@" )
|
||||||
|
}
|
||||||
|
|
||||||
|
# Hermetic variant: neutralizes every identity/credential source the wrapper
|
||||||
|
# consults so parse-acceptance arms fail at credential resolution in ANY cwd
|
||||||
|
# repo (see test-issue-comment-usage-contract.sh for the measured incident).
|
||||||
|
run_wrapper_sandboxed() {
|
||||||
|
mkdir -p "$WORK_DIR/home" "$WORK_DIR/xdg"
|
||||||
|
(
|
||||||
|
cd "$WORK_DIR"
|
||||||
|
PATH="$BIN_DIR:$PATH" HOME="$WORK_DIR/home" XDG_CONFIG_HOME="$WORK_DIR/xdg" \
|
||||||
|
MOSAIC_GIT_IDENTITY="" MOSAIC_BRAIN_HOME="" \
|
||||||
|
"$SCRIPT_DIR/pr-create.sh" "$@"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
echo "FAIL: $*" >&2
|
||||||
|
echo "--- stderr ---" >&2
|
||||||
|
cat "$ERR_FILE" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
expect_rc() { # expect_rc <want> <desc> <args...>
|
||||||
|
local want="$1" desc="$2" rc=0
|
||||||
|
shift 2
|
||||||
|
run_wrapper "$@" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
|
||||||
|
[[ "$rc" -eq "$want" ]] || fail "$desc: rc=$rc, want $want"
|
||||||
|
}
|
||||||
|
|
||||||
|
expect_stderr() { # expect_stderr <pattern> <desc>
|
||||||
|
grep -q "$1" "$ERR_FILE" || fail "$2: stderr missing '$1'"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 1. Help exits 0 and prints usage.
|
||||||
|
expect_rc 0 "--help exits 0" --help
|
||||||
|
grep -q "Usage: pr-create.sh" "$OUT_FILE" || fail "--help did not print usage"
|
||||||
|
expect_rc 0 "-h exits 0" -h
|
||||||
|
|
||||||
|
# 2. Unknown option: rc 2, stderr.
|
||||||
|
expect_rc 2 "unknown option exits 2" --bogus
|
||||||
|
expect_stderr "[Uu]nknown option" "unknown option names itself on stderr"
|
||||||
|
|
||||||
|
# 3. Missing required PR number: rc 2, stderr.
|
||||||
|
expect_rc 2 "missing -t exits 2"
|
||||||
|
expect_stderr "Title is required" "missing -t message on stderr"
|
||||||
|
|
||||||
|
|
||||||
|
# 4. Value-less flags: rc 2 with "requires a value" on stderr.
|
||||||
|
for flag in -t -b -B -H -l -m -i --title --body --base --head --labels --milestone --issue; do
|
||||||
|
expect_rc 2 "value-less $flag exits 2" "$flag"
|
||||||
|
expect_stderr "requires a value" "value-less $flag message on stderr"
|
||||||
|
done
|
||||||
|
|
||||||
|
# 4a. An option-like value is a MISSING value, not a value (codex PR #1464:
|
||||||
|
# -b --help previously consumed --help as the body and performed the write).
|
||||||
|
expect_rc 2 "option-like value rejected" -t smoke -b --help
|
||||||
|
expect_rc 2 "short flag value rejected" -t smoke -b -h
|
||||||
|
expect_stderr "requires a value" "short flag value message on stderr"
|
||||||
|
expect_stderr "requires a value" "option-like value message on stderr"
|
||||||
|
|
||||||
|
# 4b. Parser-failure arms (1-4) must have performed ZERO provider contact.
|
||||||
|
if [[ -s "$PROBE_LOG" ]]; then
|
||||||
|
echo "FAIL: a parser-failure arm contacted a provider:" >&2
|
||||||
|
cat "$PROBE_LOG" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 5. Alias acceptance under the sandbox: both -b and -c carry a value past
|
||||||
|
# parsing; the run fails at credential resolution nonzero and NOT 2.
|
||||||
|
for flag in -b; do
|
||||||
|
rc=0
|
||||||
|
run_wrapper_sandboxed -t "smoke" "$flag" "value" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
|
||||||
|
[[ "$rc" -ne 0 ]] || fail "$flag arm unexpectedly succeeded in the sandbox"
|
||||||
|
[[ "$rc" -ne 2 ]] || fail "$flag arm misclassified credential failure as a usage error"
|
||||||
|
done
|
||||||
|
|
||||||
|
# 6. Post-sandbox provider assertions are intentionally NOT applied here:
|
||||||
|
# pr-close's gitea path attempts a tea WRITE (tea pr comment) when a
|
||||||
|
# comment parses, then falls back to the API. Hermeticity for this
|
||||||
|
# wrapper comes from the FAILING stubs (exit 99), not from non-contact —
|
||||||
|
# the arm above proves only parse acceptance and non-usage classification.
|
||||||
|
# Parser-failure arms (1-4) remain zero-contact (asserted at 4b).
|
||||||
|
|
||||||
|
echo "pr-create.sh usage-contract regression passed (R1/R4)"
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Usage-error contract for pr-edit.sh (R4, 2026-08-28).
|
||||||
|
#
|
||||||
|
# issue-edit already uses long-flag-first parsing (-i/--issue, -t/--title,
|
||||||
|
# -b/--body, -l/--labels, -m/--milestone); this adds the rc=2 usage-error
|
||||||
|
# contract, value checks, and the no-provider-contact proof. Required: -i.
|
||||||
|
#
|
||||||
|
# Arms:
|
||||||
|
# 1. --help and -h exit 0 and print usage.
|
||||||
|
# 2. Unknown option exits 2 with the message on stderr.
|
||||||
|
# 3. Missing required -i exits 2 (stderr).
|
||||||
|
# 4. A value-less flag (-i -b -c and long forms) exits 2 (stderr).
|
||||||
|
# 5. -b and -c both pass parsing (sandboxed runner: the run then fails
|
||||||
|
# at credential resolution, nonzero and NOT 2) — no real token is
|
||||||
|
# ever read and no provider is contacted.
|
||||||
|
# 6. No arm performs any provider request (PATH shims record every
|
||||||
|
# invocation; the probe log must stay empty).
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-edit-usage}"
|
||||||
|
BIN_DIR="$WORK_DIR/bin"
|
||||||
|
PROBE_LOG="$WORK_DIR/provider-probes.log"
|
||||||
|
OUT_FILE="$WORK_DIR/out.log"
|
||||||
|
ERR_FILE="$WORK_DIR/err.log"
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
rm -rf "$WORK_DIR"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
mkdir -p "$BIN_DIR"
|
||||||
|
: > "$PROBE_LOG"
|
||||||
|
|
||||||
|
# Unlike the issue suites, these stubs FAIL (exit 99): pr-close has an
|
||||||
|
# API fallback that treats a successful curl as a closed PR, so exit-0
|
||||||
|
# stubs would let the sandbox arms "succeed" (measured 2026-08-28).
|
||||||
|
for tool in gh tea curl; do
|
||||||
|
cat > "$BIN_DIR/$tool" <<STUB
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
echo "$tool \$*" >> "$PROBE_LOG"
|
||||||
|
exit 99
|
||||||
|
STUB
|
||||||
|
chmod +x "$BIN_DIR/$tool"
|
||||||
|
done
|
||||||
|
|
||||||
|
run_wrapper() {
|
||||||
|
( cd "$WORK_DIR" && PATH="$BIN_DIR:$PATH" "$SCRIPT_DIR/pr-edit.sh" "$@" )
|
||||||
|
}
|
||||||
|
|
||||||
|
# Hermetic variant: neutralizes every identity/credential source the wrapper
|
||||||
|
# consults so parse-acceptance arms fail at credential resolution in ANY cwd
|
||||||
|
# repo (see test-issue-comment-usage-contract.sh for the measured incident).
|
||||||
|
run_wrapper_sandboxed() {
|
||||||
|
mkdir -p "$WORK_DIR/home" "$WORK_DIR/xdg"
|
||||||
|
(
|
||||||
|
cd "$WORK_DIR"
|
||||||
|
PATH="$BIN_DIR:$PATH" HOME="$WORK_DIR/home" XDG_CONFIG_HOME="$WORK_DIR/xdg" \
|
||||||
|
MOSAIC_GIT_IDENTITY="" MOSAIC_BRAIN_HOME="" \
|
||||||
|
"$SCRIPT_DIR/pr-edit.sh" "$@"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
echo "FAIL: $*" >&2
|
||||||
|
echo "--- stderr ---" >&2
|
||||||
|
cat "$ERR_FILE" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
expect_rc() { # expect_rc <want> <desc> <args...>
|
||||||
|
local want="$1" desc="$2" rc=0
|
||||||
|
shift 2
|
||||||
|
run_wrapper "$@" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
|
||||||
|
[[ "$rc" -eq "$want" ]] || fail "$desc: rc=$rc, want $want"
|
||||||
|
}
|
||||||
|
|
||||||
|
expect_stderr() { # expect_stderr <pattern> <desc>
|
||||||
|
grep -q "$1" "$ERR_FILE" || fail "$2: stderr missing '$1'"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 1. Help exits 0 and prints usage.
|
||||||
|
expect_rc 0 "--help exits 0" --help
|
||||||
|
grep -q "Usage: pr-edit.sh" "$OUT_FILE" || fail "--help did not print usage"
|
||||||
|
expect_rc 0 "-h exits 0" -h
|
||||||
|
|
||||||
|
# 2. Unknown option: rc 2, stderr.
|
||||||
|
expect_rc 2 "unknown option exits 2" --bogus
|
||||||
|
expect_stderr "[Uu]nknown option" "unknown option names itself on stderr"
|
||||||
|
|
||||||
|
# 3. Missing required PR number: rc 2, stderr.
|
||||||
|
expect_rc 2 "missing -n exits 2"
|
||||||
|
expect_stderr "Pull request number is required" "missing -n message on stderr"
|
||||||
|
expect_rc 2 "no edit option exits 2" -n 5
|
||||||
|
expect_stderr "At least one edit option is required" "no-edit-option message on stderr"
|
||||||
|
expect_rc 2 "non-integer PR number exits 2" -n abc -t x
|
||||||
|
expect_stderr "positive integer" "integer check on stderr"
|
||||||
|
expect_rc 2 "mutually exclusive draft/ready exits 2" -n 5 --draft --ready
|
||||||
|
expect_stderr "mutually exclusive" "mutual exclusion on stderr"
|
||||||
|
expect_rc 2 "bad repo format exits 2" -n 5 -t x -r not-a-slug
|
||||||
|
expect_stderr "OWNER/REPO" "repo format on stderr"
|
||||||
|
|
||||||
|
|
||||||
|
# 4. Value-less flags: rc 2 with "requires a value" on stderr.
|
||||||
|
for flag in -n -t -b -B -l -r -H --number --title --body --base --login --repo --host; do
|
||||||
|
expect_rc 2 "value-less $flag exits 2" "$flag"
|
||||||
|
expect_stderr "requires a value" "value-less $flag message on stderr"
|
||||||
|
done
|
||||||
|
|
||||||
|
# 4a. An option-like value is a MISSING value, not a value (codex PR #1464:
|
||||||
|
# -b --help previously consumed --help as the body and performed the write).
|
||||||
|
expect_rc 2 "option-like value rejected" -n 5 -t smoke -b --help
|
||||||
|
expect_rc 2 "short flag value rejected" -n 5 -t smoke -b -h
|
||||||
|
expect_stderr "requires a value" "short flag value message on stderr"
|
||||||
|
expect_stderr "requires a value" "option-like value message on stderr"
|
||||||
|
|
||||||
|
# 4b. Parser-failure arms (1-4) must have performed ZERO provider contact.
|
||||||
|
if [[ -s "$PROBE_LOG" ]]; then
|
||||||
|
echo "FAIL: a parser-failure arm contacted a provider:" >&2
|
||||||
|
cat "$PROBE_LOG" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 5. Alias acceptance under the sandbox: both -b and -c carry a value past
|
||||||
|
# parsing; the run fails at credential resolution nonzero and NOT 2.
|
||||||
|
for flag in -b; do
|
||||||
|
rc=0
|
||||||
|
run_wrapper_sandboxed -n 5 "$flag" "value" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
|
||||||
|
[[ "$rc" -ne 0 ]] || fail "$flag arm unexpectedly succeeded in the sandbox"
|
||||||
|
[[ "$rc" -ne 2 ]] || fail "$flag arm misclassified credential failure as a usage error"
|
||||||
|
done
|
||||||
|
|
||||||
|
# 6. Post-sandbox provider assertions are intentionally NOT applied here:
|
||||||
|
# pr-close's gitea path attempts a tea WRITE (tea pr comment) when a
|
||||||
|
# comment parses, then falls back to the API. Hermeticity for this
|
||||||
|
# wrapper comes from the FAILING stubs (exit 99), not from non-contact —
|
||||||
|
# the arm above proves only parse acceptance and non-usage classification.
|
||||||
|
# Parser-failure arms (1-4) remain zero-contact (asserted at 4b).
|
||||||
|
|
||||||
|
echo "pr-edit.sh usage-contract regression passed (R1/R4)"
|
||||||
Regular → Executable
@@ -218,7 +218,7 @@ if grep -q 'Unknown option' "$OUTPUT_FILE"; then
|
|||||||
cat "$OUTPUT_FILE" >&2
|
cat "$OUTPUT_FILE" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
grep -q 'Unknown action: bogus-action' "$OUTPUT_FILE"
|
grep -q "unknown action 'bogus-action'" "$OUTPUT_FILE"
|
||||||
|
|
||||||
# --- Case 2: -h/--help documents both overrides.
|
# --- Case 2: -h/--help documents both overrides.
|
||||||
HELP_TEXT="$("$SCRIPT_DIR/pr-review.sh" -h)"
|
HELP_TEXT="$("$SCRIPT_DIR/pr-review.sh" -h)"
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Usage-error contract for pr-review.sh (R1/R4, 2026-08-28).
|
||||||
|
#
|
||||||
|
# R4: usage errors print to STDERR and exit 2, distinct from provider,
|
||||||
|
# credential, and verification failures (exit 1). R1: -b/--body is the
|
||||||
|
# canonical comment flag; -c/--comment remains a compatible alias.
|
||||||
|
# Required: -n AND -a. The comment is required only for the
|
||||||
|
# request-changes action (semantic usage check, also rc 2).
|
||||||
|
#
|
||||||
|
# Arms:
|
||||||
|
# 1. --help and -h exit 0 and print usage.
|
||||||
|
# 2. Unknown option exits 2 with the message on stderr.
|
||||||
|
# 3. Missing required -i exits 2 (stderr).
|
||||||
|
# 4. A value-less flag (-i -b -c and long forms) exits 2 (stderr).
|
||||||
|
# 5. -b and -c both pass parsing (sandboxed runner: the run then fails
|
||||||
|
# at credential resolution, nonzero and NOT 2) — no real token is
|
||||||
|
# ever read and no provider is contacted.
|
||||||
|
# 6. No arm performs any provider request (PATH shims record every
|
||||||
|
# invocation; the probe log must stay empty).
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-review-usage}"
|
||||||
|
BIN_DIR="$WORK_DIR/bin"
|
||||||
|
PROBE_LOG="$WORK_DIR/provider-probes.log"
|
||||||
|
OUT_FILE="$WORK_DIR/out.log"
|
||||||
|
ERR_FILE="$WORK_DIR/err.log"
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
rm -rf "$WORK_DIR"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
mkdir -p "$BIN_DIR"
|
||||||
|
: > "$PROBE_LOG"
|
||||||
|
|
||||||
|
# Unlike the issue suites, these stubs FAIL (exit 99): pr-close has an
|
||||||
|
# API fallback that treats a successful curl as a closed PR, so exit-0
|
||||||
|
# stubs would let the sandbox arms "succeed" (measured 2026-08-28).
|
||||||
|
for tool in gh tea curl; do
|
||||||
|
cat > "$BIN_DIR/$tool" <<STUB
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
echo "$tool \$*" >> "$PROBE_LOG"
|
||||||
|
exit 99
|
||||||
|
STUB
|
||||||
|
chmod +x "$BIN_DIR/$tool"
|
||||||
|
done
|
||||||
|
|
||||||
|
run_wrapper() {
|
||||||
|
( cd "$WORK_DIR" && PATH="$BIN_DIR:$PATH" "$SCRIPT_DIR/pr-review.sh" "$@" )
|
||||||
|
}
|
||||||
|
|
||||||
|
# Hermetic variant: neutralizes every identity/credential source the wrapper
|
||||||
|
# consults so parse-acceptance arms fail at credential resolution in ANY cwd
|
||||||
|
# repo (see test-issue-comment-usage-contract.sh for the measured incident).
|
||||||
|
run_wrapper_sandboxed() {
|
||||||
|
mkdir -p "$WORK_DIR/home" "$WORK_DIR/xdg"
|
||||||
|
(
|
||||||
|
cd "$WORK_DIR"
|
||||||
|
PATH="$BIN_DIR:$PATH" HOME="$WORK_DIR/home" XDG_CONFIG_HOME="$WORK_DIR/xdg" \
|
||||||
|
MOSAIC_GIT_IDENTITY="" MOSAIC_BRAIN_HOME="" \
|
||||||
|
"$SCRIPT_DIR/pr-review.sh" "$@"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
echo "FAIL: $*" >&2
|
||||||
|
echo "--- stderr ---" >&2
|
||||||
|
cat "$ERR_FILE" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
expect_rc() { # expect_rc <want> <desc> <args...>
|
||||||
|
local want="$1" desc="$2" rc=0
|
||||||
|
shift 2
|
||||||
|
run_wrapper "$@" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
|
||||||
|
[[ "$rc" -eq "$want" ]] || fail "$desc: rc=$rc, want $want"
|
||||||
|
}
|
||||||
|
|
||||||
|
expect_stderr() { # expect_stderr <pattern> <desc>
|
||||||
|
grep -q "$1" "$ERR_FILE" || fail "$2: stderr missing '$1'"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 1. Help exits 0 and prints usage.
|
||||||
|
expect_rc 0 "--help exits 0" --help
|
||||||
|
grep -q "Usage: pr-review.sh" "$OUT_FILE" || fail "--help did not print usage"
|
||||||
|
expect_rc 0 "-h exits 0" -h
|
||||||
|
|
||||||
|
# 2. Unknown option: rc 2, stderr.
|
||||||
|
expect_rc 2 "unknown option exits 2" --bogus
|
||||||
|
expect_stderr "unknown option" "unknown option names itself on stderr"
|
||||||
|
|
||||||
|
# 3. Missing required PR number: rc 2, stderr.
|
||||||
|
expect_rc 2 "missing -n exits 2"
|
||||||
|
expect_stderr "PR number is required" "missing -n message on stderr"
|
||||||
|
expect_rc 2 "missing -a exits 2" -n 5
|
||||||
|
expect_stderr "Action is required" "missing -a message on stderr"
|
||||||
|
expect_rc 2 "request-changes without comment exits 2" -n 5 -a request-changes
|
||||||
|
expect_stderr "comment required for request-changes" "request-changes message on stderr"
|
||||||
|
expect_rc 2 "comment without body exits 2 pre-detection" -n 5 -a comment
|
||||||
|
expect_stderr "comment required for comment" "comment-without-body message on stderr"
|
||||||
|
expect_rc 2 "invalid action exits 2 pre-detection" -n 5 -a bogus
|
||||||
|
expect_stderr "unknown action" "invalid action message on stderr"
|
||||||
|
# Invalid-action arms must not contact any provider (validation precedes
|
||||||
|
# detect_platform): probe log empty at this point.
|
||||||
|
if [[ -s "$PROBE_LOG" ]]; then
|
||||||
|
echo "FAIL: an invalid-action arm contacted a provider:" >&2
|
||||||
|
cat "$PROBE_LOG" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 4. Value-less flags: rc 2 with "requires a value" on stderr.
|
||||||
|
for flag in -n -a -b -c -l -r --number --action --body --comment --login --repo; do
|
||||||
|
expect_rc 2 "value-less $flag exits 2" "$flag"
|
||||||
|
expect_stderr "requires a value" "value-less $flag message on stderr"
|
||||||
|
done
|
||||||
|
|
||||||
|
# 4a. An option-like value is a MISSING value, not a value (codex PR #1464:
|
||||||
|
# -b --help previously consumed --help as the body and performed the write).
|
||||||
|
expect_rc 2 "option-like value rejected" -n 5 -a comment -b --help
|
||||||
|
expect_rc 2 "short flag value rejected" -n 5 -a comment -b -h
|
||||||
|
expect_stderr "requires a value" "short flag value message on stderr"
|
||||||
|
expect_stderr "requires a value" "option-like value message on stderr"
|
||||||
|
|
||||||
|
# 4b. Parser-failure arms (1-4) must have performed ZERO provider contact.
|
||||||
|
if [[ -s "$PROBE_LOG" ]]; then
|
||||||
|
echo "FAIL: a parser-failure arm contacted a provider:" >&2
|
||||||
|
cat "$PROBE_LOG" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 5. Alias acceptance under the sandbox: both -b and -c carry a value past
|
||||||
|
# parsing; the run fails at credential resolution nonzero and NOT 2.
|
||||||
|
for flag in -b -c; do
|
||||||
|
rc=0
|
||||||
|
run_wrapper_sandboxed -n 5 -a comment "$flag" "review note" >"$OUT_FILE" 2>"$ERR_FILE" || rc=$?
|
||||||
|
[[ "$rc" -ne 0 ]] || fail "$flag arm unexpectedly succeeded in the sandbox"
|
||||||
|
[[ "$rc" -ne 2 ]] || fail "$flag arm misclassified credential failure as a usage error"
|
||||||
|
done
|
||||||
|
|
||||||
|
# 6. Post-sandbox provider assertions are intentionally NOT applied here:
|
||||||
|
# pr-close's gitea path attempts a tea WRITE (tea pr comment) when a
|
||||||
|
# comment parses, then falls back to the API. Hermeticity for this
|
||||||
|
# wrapper comes from the FAILING stubs (exit 99), not from non-contact —
|
||||||
|
# the arm above proves only parse acceptance and non-usage classification.
|
||||||
|
# Parser-failure arms (1-4) remain zero-contact (asserted at 4b).
|
||||||
|
|
||||||
|
echo "pr-review.sh usage-contract regression passed (R1/R4)"
|
||||||
@@ -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": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.sh && python3 framework/tools/quality/scripts/test-framework-drift-check.py && bash framework/tools/quality/scripts/test-framework-drift-doctor.sh && bash framework/systemd/user/test-fleet-units.sh && python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/promotion_binding_unittest.py && python3 src/lease-broker/promotion_trigger_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/receipt_observer_client_unittest.py && python3 src/lease-broker/invariant_r_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/lease-broker/revoke_noop_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_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-edit.sh && bash framework/tools/git/test-pr-create-fallback-default-base.sh && bash framework/tools/git/test-repo-decl-consumption.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-no-status.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-ci-queue-wait-tristate.sh && bash framework/tools/git/test-ci-queue-wait-github-checks.sh && bash framework/tools/git/test-ci-queue-wait-no-ci-expected.sh && bash framework/tools/git/test-pr-merge-queue-branch.sh && bash framework/tools/git/test-pr-merge-no-ci-expected.sh && bash framework/tools/git/test-pr-merge-fork-ci-status.sh && bash framework/tools/git/test-pr-merge-head-pin.sh && bash framework/tools/git/test-pr-merge-message-field.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/git/test-issue-comment-usage-contract.sh && bash framework/tools/git/test-issue-comment-readback.sh && bash framework/tools/git/test-explain-diagnostic-status-neutral.sh && bash framework/tools/git/test-detect-platform-outside-repo.sh && bash framework/tools/woodpecker/test-terminal-green-contract.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/_scripts/test-mosaic-init-rce.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-store-enqueue-race.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-digest-quarantine.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh && bash framework/tools/wake/test-wake-preimage.sh && bash framework/tools/wake/test-wake-install.sh && bash framework/tools/glpi/test-list-http-status.sh && bash framework/tools/orchestrator/test-board-roll.sh && bash framework/tools/woodpecker/test-ci-wait-exit-matrix.sh && bash framework/tools/_scripts/test-fleet-transport-check.sh && bash framework/tools/_scripts/test-brain-home-check.sh && bash framework/tools/_scripts/test-structure-anchor-check.sh && bash framework/tools/fleet/test-agent-session-broker-preflight.sh && bash framework/tools/fleet/test-agent-session-legacy-socket-guard.sh && bash framework/tools/git/test-grant-reviewer.sh"
|
"test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.sh && python3 framework/tools/quality/scripts/test-framework-drift-check.py && bash framework/tools/quality/scripts/test-framework-drift-doctor.sh && bash framework/systemd/user/test-fleet-units.sh && python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/promotion_binding_unittest.py && python3 src/lease-broker/promotion_trigger_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/receipt_observer_client_unittest.py && python3 src/lease-broker/invariant_r_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/lease-broker/revoke_noop_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_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-edit.sh && bash framework/tools/git/test-pr-create-fallback-default-base.sh && bash framework/tools/git/test-repo-decl-consumption.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-no-status.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-ci-queue-wait-tristate.sh && bash framework/tools/git/test-ci-queue-wait-github-checks.sh && bash framework/tools/git/test-ci-queue-wait-no-ci-expected.sh && bash framework/tools/git/test-pr-merge-queue-branch.sh && bash framework/tools/git/test-pr-merge-no-ci-expected.sh && bash framework/tools/git/test-pr-merge-fork-ci-status.sh && bash framework/tools/git/test-pr-merge-head-pin.sh && bash framework/tools/git/test-pr-merge-message-field.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/git/test-issue-comment-usage-contract.sh && bash framework/tools/git/test-issue-comment-readback.sh && bash framework/tools/git/test-issue-close-usage-contract.sh && bash framework/tools/git/test-issue-reopen-usage-contract.sh && bash framework/tools/git/test-pr-close-usage-contract.sh && bash framework/tools/git/test-pr-review-usage-contract.sh && bash framework/tools/git/test-issue-edit-usage-contract.sh && bash framework/tools/git/test-issue-create-usage-contract.sh && bash framework/tools/git/test-pr-edit-usage-contract.sh && bash framework/tools/git/test-pr-create-usage-contract.sh && bash framework/tools/git/test-issue-assign-usage-contract.sh && bash framework/tools/git/test-milestone-close-usage-contract.sh && bash framework/tools/git/test-milestone-list-usage-contract.sh && bash framework/tools/git/test-issue-view-usage-contract.sh && bash framework/tools/git/test-issue-list-usage-contract.sh && bash framework/tools/git/test-milestone-create-usage-contract.sh && bash framework/tools/git/test-lane-brief-usage-contract.sh && bash framework/tools/git/test-explain-diagnostic-status-neutral.sh && bash framework/tools/git/test-detect-platform-outside-repo.sh && bash framework/tools/woodpecker/test-terminal-green-contract.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/_scripts/test-mosaic-init-rce.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-store-enqueue-race.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-digest-quarantine.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh && bash framework/tools/wake/test-wake-preimage.sh && bash framework/tools/wake/test-wake-install.sh && bash framework/tools/glpi/test-list-http-status.sh && bash framework/tools/orchestrator/test-board-roll.sh && bash framework/tools/woodpecker/test-ci-wait-exit-matrix.sh && bash framework/tools/_scripts/test-fleet-transport-check.sh && bash framework/tools/_scripts/test-brain-home-check.sh && bash framework/tools/_scripts/test-structure-anchor-check.sh && bash framework/tools/fleet/test-agent-session-broker-preflight.sh && bash framework/tools/fleet/test-agent-session-legacy-socket-guard.sh && bash framework/tools/git/test-grant-reviewer.sh"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mosaicstack/brain": "workspace:*",
|
"@mosaicstack/brain": "workspace:*",
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ import { registerQualityRails } from '@mosaicstack/quality-rails';
|
|||||||
import { registerQueueCommand } from '@mosaicstack/queue';
|
import { registerQueueCommand } from '@mosaicstack/queue';
|
||||||
import { registerStorageCommand } from '@mosaicstack/storage';
|
import { registerStorageCommand } from '@mosaicstack/storage';
|
||||||
import { registerTelemetryCommand } from './commands/telemetry.js';
|
import { registerTelemetryCommand } from './commands/telemetry.js';
|
||||||
|
import { registerCommsCommand } from './commands/comms.js';
|
||||||
|
import { registerQCommand } from './commands/q.js';
|
||||||
|
import { registerWatchCommand } from './commands/watch.js';
|
||||||
import { registerAgentCommand } from './commands/agent.js';
|
import { registerAgentCommand } from './commands/agent.js';
|
||||||
import { registerInteractionCommand } from './commands/interaction.js';
|
import { registerInteractionCommand } from './commands/interaction.js';
|
||||||
import { registerConfigCommand } from './commands/config.js';
|
import { registerConfigCommand } from './commands/config.js';
|
||||||
@@ -428,6 +431,9 @@ registerSkillCommand(program);
|
|||||||
// ─── telemetry ───────────────────────────────────────────────────────────────
|
// ─── telemetry ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
registerTelemetryCommand(program);
|
registerTelemetryCommand(program);
|
||||||
|
registerWatchCommand(program);
|
||||||
|
registerQCommand(program);
|
||||||
|
registerCommsCommand(program);
|
||||||
|
|
||||||
// ─── update ─────────────────────────────────────────────────────────────
|
// ─── update ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
|
import { accessSync, constants } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
import { resolveBrainHome } from '../fleet/brain-home.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared brain-tool dispatch (fleet CLI integration, Jason ruling
|
||||||
|
* 2026-08-28): the npm package embeds the COMMAND SURFACE; operator-owned
|
||||||
|
* implementations stay in the brain (tools/). Commands resolve the brain
|
||||||
|
* home (MOSAIC_BRAIN_HOME wins — see brain-home.ts) and exec the tool
|
||||||
|
* there. Nothing operator-specific ships inside the package.
|
||||||
|
*
|
||||||
|
* Pass-through contract: arguments, stdout/stderr, and the exit code belong
|
||||||
|
* to the tool. The CLI adds nothing on success; absent tools fail loudly
|
||||||
|
* with the resolved path (127) instead of guessing.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Absolute path of a brain-relative tool. */
|
||||||
|
export function resolveBrainTool(mosaicHome: string, relPath: string): string {
|
||||||
|
return join(resolveBrainHome(mosaicHome), ...relPath.split('/'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map a spawnSync result + tool existence to the CLI exit status. */
|
||||||
|
export function exitStatusFor(
|
||||||
|
result: { status: number | null; error?: NodeJS.ErrnoException },
|
||||||
|
toolExists: boolean,
|
||||||
|
): number {
|
||||||
|
if (!toolExists) return 127;
|
||||||
|
if (result.status !== null) return result.status;
|
||||||
|
return 125; // killed by signal / could not run
|
||||||
|
}
|
||||||
|
|
||||||
|
export function brainToolExists(tool: string): boolean {
|
||||||
|
try {
|
||||||
|
accessSync(tool, constants.X_OK);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exec a brain tool with full pass-through. `interpreter` runs the tool
|
||||||
|
* through e.g. python3 (renderers); omit it for executable scripts.
|
||||||
|
* Returns the process exit status; callers assign it to process.exitCode.
|
||||||
|
*/
|
||||||
|
export function execBrainTool(
|
||||||
|
mosaicHome: string,
|
||||||
|
relPath: string,
|
||||||
|
args: string[],
|
||||||
|
interpreter?: string,
|
||||||
|
): number {
|
||||||
|
const tool = resolveBrainTool(mosaicHome, relPath);
|
||||||
|
if (!brainToolExists(tool)) {
|
||||||
|
console.error(
|
||||||
|
`mosaic: brain tool not found (expected ${tool}). ` +
|
||||||
|
'Tool suites live in the brain tree under tools/; ' +
|
||||||
|
'check MOSAIC_BRAIN_HOME or the brain checkout.',
|
||||||
|
);
|
||||||
|
return 127;
|
||||||
|
}
|
||||||
|
const result = interpreter
|
||||||
|
? spawnSync(interpreter, [tool, ...args], { stdio: 'inherit', env: process.env })
|
||||||
|
: spawnSync(tool, args, { stdio: 'inherit', env: process.env });
|
||||||
|
return exitStatusFor(result, true);
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import {
|
||||||
|
chmodSync,
|
||||||
|
existsSync,
|
||||||
|
mkdirSync,
|
||||||
|
mkdtempSync,
|
||||||
|
writeFileSync,
|
||||||
|
readFileSync,
|
||||||
|
} from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
import { Command } from 'commander';
|
||||||
|
import { afterEach, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { fleetCommsSendArgs, registerCommsCommand, tmuxSendArgs } from './comms.js';
|
||||||
|
|
||||||
|
describe('arg translation', () => {
|
||||||
|
it('tmux path: -s/-C/-L/-f/-m per agent-send.sh getopts', () => {
|
||||||
|
expect(tmuxSendArgs('orch-01', 'hello', {})).toEqual(['-s', 'orch-01', '-m', 'hello']);
|
||||||
|
expect(
|
||||||
|
tmuxSendArgs('orch-01', 'unused', {
|
||||||
|
class: 'actionable',
|
||||||
|
socket: 'mosaic-fleet',
|
||||||
|
file: '/tmp/body.txt',
|
||||||
|
}),
|
||||||
|
).toEqual(['-s', 'orch-01', '-C', 'actionable', '-L', 'mosaic-fleet', '-f', '/tmp/body.txt']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fleet-comms path: -t site/agent and -c class', () => {
|
||||||
|
expect(fleetCommsSendArgs('usc', 'fred', 'hi', {})).toEqual(['-t', 'usc/fred', '-m', 'hi']);
|
||||||
|
expect(fleetCommsSendArgs('usc', 'fred', 'hi', { class: 'human' })).toEqual([
|
||||||
|
'-t',
|
||||||
|
'usc/fred',
|
||||||
|
'-c',
|
||||||
|
'human',
|
||||||
|
'-m',
|
||||||
|
'hi',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('registerCommsCommand routing', () => {
|
||||||
|
const savedBrain = process.env['MOSAIC_BRAIN_HOME'];
|
||||||
|
const savedRepo = process.env['MOSAIC_FLEET_COMMS_REPO'];
|
||||||
|
const savedAgent = process.env['MOSAIC_AGENT_NAME'];
|
||||||
|
afterEach(() => {
|
||||||
|
for (const [k, v] of [
|
||||||
|
['MOSAIC_BRAIN_HOME', savedBrain],
|
||||||
|
['MOSAIC_FLEET_COMMS_REPO', savedRepo],
|
||||||
|
['MOSAIC_AGENT_NAME', savedAgent],
|
||||||
|
] as const) {
|
||||||
|
if (v === undefined) delete process.env[k];
|
||||||
|
else process.env[k] = v;
|
||||||
|
}
|
||||||
|
process.exitCode = undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
function fixture(): { brain: string; repo: string; tmuxLog: string; commsLog: string } {
|
||||||
|
const brain = mkdtempSync(join(tmpdir(), 'comms-brain-'));
|
||||||
|
const repo = mkdtempSync(join(tmpdir(), 'comms-repo-'));
|
||||||
|
mkdirSync(join(brain, 'tools', 'tmux'), { recursive: true });
|
||||||
|
mkdirSync(join(repo, 'tools'), { recursive: true });
|
||||||
|
const tmuxLog = join(brain, 'tmux.log');
|
||||||
|
const commsLog = join(repo, 'comms.log');
|
||||||
|
writeFileSync(
|
||||||
|
join(brain, 'tools', 'tmux', 'agent-send.sh'),
|
||||||
|
`#!/usr/bin/env bash\nprintf '%s\\n' "$*" >> ${JSON.stringify(tmuxLog)}\nexit 7\n`,
|
||||||
|
);
|
||||||
|
writeFileSync(
|
||||||
|
join(repo, 'tools', 'comms-send.sh'),
|
||||||
|
`#!/usr/bin/env bash\nprintf '%s\\n' "$*" >> ${JSON.stringify(commsLog)}\nprintf 'FLEET_COMMS_REPO=%s FLEET_COMMS_SITE=%s\\n' "$FLEET_COMMS_REPO" "$FLEET_COMMS_SITE" >> ${JSON.stringify(commsLog)}\nexit 5\n`,
|
||||||
|
);
|
||||||
|
chmodSync(join(brain, 'tools', 'tmux', 'agent-send.sh'), 0o755);
|
||||||
|
chmodSync(join(repo, 'tools', 'comms-send.sh'), 0o755);
|
||||||
|
process.env['MOSAIC_BRAIN_HOME'] = brain;
|
||||||
|
process.env['MOSAIC_FLEET_COMMS_REPO'] = repo;
|
||||||
|
process.env['MOSAIC_AGENT_NAME'] = 'tester';
|
||||||
|
return { brain, repo, tmuxLog, commsLog };
|
||||||
|
}
|
||||||
|
|
||||||
|
it('default routes same-host via agent-send with translated flags and passes rc through', async () => {
|
||||||
|
const f = fixture();
|
||||||
|
const program = new Command();
|
||||||
|
registerCommsCommand(program);
|
||||||
|
await program.parseAsync(
|
||||||
|
[
|
||||||
|
'comms',
|
||||||
|
'send',
|
||||||
|
'orch-01',
|
||||||
|
'--class',
|
||||||
|
'actionable',
|
||||||
|
'--socket',
|
||||||
|
'mosaic-fleet',
|
||||||
|
'verdict',
|
||||||
|
'landed',
|
||||||
|
],
|
||||||
|
{ from: 'user' },
|
||||||
|
);
|
||||||
|
expect(process.exitCode).toBe(7);
|
||||||
|
expect(readFileSync(f.tmuxLog, 'utf8').trim()).toBe(
|
||||||
|
'-s orch-01 -C actionable -L mosaic-fleet -m verdict landed',
|
||||||
|
);
|
||||||
|
expect(existsSync(f.commsLog)).toBe(false); // inter-site tool never invoked
|
||||||
|
});
|
||||||
|
|
||||||
|
it('--site routes inter-site via comms-send with site-prefixed target and passes rc through', async () => {
|
||||||
|
const f = fixture();
|
||||||
|
const program = new Command();
|
||||||
|
registerCommsCommand(program);
|
||||||
|
await program.parseAsync(
|
||||||
|
['comms', 'send', 'fred', '--site', 'usc', '--class', 'human', 'hello', 'there'],
|
||||||
|
{ from: 'user' },
|
||||||
|
);
|
||||||
|
expect(process.exitCode).toBe(5);
|
||||||
|
expect(readFileSync(f.commsLog, 'utf8').split('\n')[0]?.trim()).toBe(
|
||||||
|
'-t usc/fred -c human -m hello there',
|
||||||
|
);
|
||||||
|
// The sender must bind comms-send.sh to the SELECTED repo (codex 9c8b6ebf).
|
||||||
|
expect(readFileSync(f.commsLog, 'utf8')).toContain(
|
||||||
|
`FLEET_COMMS_REPO=${f.repo} FLEET_COMMS_SITE=usc`,
|
||||||
|
);
|
||||||
|
expect(existsSync(f.tmuxLog)).toBe(false); // same-host tool never invoked
|
||||||
|
});
|
||||||
|
|
||||||
|
it('inter-site without MOSAIC_AGENT_NAME is an invocation defect (exit 2)', async () => {
|
||||||
|
const f = fixture();
|
||||||
|
delete process.env['MOSAIC_AGENT_NAME'];
|
||||||
|
const program = new Command();
|
||||||
|
registerCommsCommand(program);
|
||||||
|
await program.parseAsync(['comms', 'send', 'fred', '--site', 'usc', 'hi'], { from: 'user' });
|
||||||
|
expect(process.exitCode).toBe(2);
|
||||||
|
expect(existsSync(f.commsLog)).toBe(false); // inter-site tool never invoked
|
||||||
|
});
|
||||||
|
|
||||||
|
it('missing fleet-comms repo fails 127 naming the expected path', async () => {
|
||||||
|
fixture();
|
||||||
|
process.env['MOSAIC_FLEET_COMMS_REPO'] = '/nonexistent-comms-repo';
|
||||||
|
const program = new Command();
|
||||||
|
registerCommsCommand(program);
|
||||||
|
await program.parseAsync(['comms', 'send', 'fred', '--site', 'usc', 'hi'], { from: 'user' });
|
||||||
|
expect(process.exitCode).toBe(127);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import type { Command } from 'commander';
|
||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
|
import { accessSync, constants } from 'node:fs';
|
||||||
|
import { homedir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
import { DEFAULT_MOSAIC_HOME } from '../constants.js';
|
||||||
|
import { execBrainTool } from './brain-dispatch.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `mosaic comms send` — routed agent messaging (FLEET-COMMS.md doctrine).
|
||||||
|
*
|
||||||
|
* Same-host (default): brain tools/tmux/agent-send.sh. Inter-site
|
||||||
|
* (--site <site>): the fleet-comms repo's comms-send.sh — never for
|
||||||
|
* local traffic (a git round trip per message; Jason 2026-08-28).
|
||||||
|
*
|
||||||
|
* Exit codes pass through BOTH paths. rc=2 (text in pane, still draft) is
|
||||||
|
* a CONTRACT, not a failure: never retry, confirm with capture-pane.
|
||||||
|
*/
|
||||||
|
export interface CommsSendOptions {
|
||||||
|
readonly class?: string;
|
||||||
|
readonly file?: string;
|
||||||
|
readonly socket?: string;
|
||||||
|
readonly site?: string;
|
||||||
|
readonly commsRepo?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function defaultCommsRepo(): string {
|
||||||
|
return process.env['MOSAIC_FLEET_COMMS_REPO'] ?? join(homedir(), 'src', 'fleet-comms');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build the agent-send.sh argv for the same-host path. */
|
||||||
|
export function tmuxSendArgs(target: string, message: string, opts: CommsSendOptions): string[] {
|
||||||
|
const args = ['-s', target];
|
||||||
|
if (opts.class) args.push('-C', opts.class);
|
||||||
|
if (opts.socket) args.push('-L', opts.socket);
|
||||||
|
if (opts.file) args.push('-f', opts.file);
|
||||||
|
else args.push('-m', message);
|
||||||
|
return args;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build the comms-send.sh argv for the inter-site path. */
|
||||||
|
export function fleetCommsSendArgs(
|
||||||
|
site: string,
|
||||||
|
target: string,
|
||||||
|
message: string,
|
||||||
|
opts: CommsSendOptions,
|
||||||
|
): string[] {
|
||||||
|
const args = ['-t', `${site}/${target}`];
|
||||||
|
if (opts.class) args.push('-c', opts.class);
|
||||||
|
args.push('-m', message);
|
||||||
|
return args;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerCommsCommand(program: Command): void {
|
||||||
|
const cmd: Command = program
|
||||||
|
.command('comms')
|
||||||
|
.description(
|
||||||
|
'Routed agent messaging: tmux same-host (default), fleet-comms inter-site (--site)',
|
||||||
|
)
|
||||||
|
.command('send')
|
||||||
|
.description('send <target> [message...] — same-host tmux unless --site is given')
|
||||||
|
.option('--class <class>', 'terminal-log | actionable | human | reaction | digest')
|
||||||
|
.option('--file <path>', 'message body from file (same-host path only)')
|
||||||
|
.option('--socket <name>', 'tmux socket for the same-host send (e.g. mosaic-fleet)')
|
||||||
|
.option('--site <site>', 'route via fleet-comms to <site>/<target>')
|
||||||
|
.option('--comms-repo <path>', 'fleet-comms checkout', defaultCommsRepo())
|
||||||
|
.argument('<target>', 'destination seat (session name)')
|
||||||
|
.argument('[message...]', 'message text (joined; or use --file)')
|
||||||
|
.action(
|
||||||
|
async (
|
||||||
|
target: string,
|
||||||
|
messageWords: string[],
|
||||||
|
opts: CommsSendOptions & Record<string, unknown>,
|
||||||
|
command: Command,
|
||||||
|
) => {
|
||||||
|
let mosaicHome: string | undefined;
|
||||||
|
for (let anc: Command | null = command; anc; anc = anc.parent) {
|
||||||
|
const v = (anc.opts() as Record<string, string | undefined>)['mosaicHome'];
|
||||||
|
if (v !== undefined) {
|
||||||
|
mosaicHome = v;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const home = mosaicHome ?? DEFAULT_MOSAIC_HOME;
|
||||||
|
const message = messageWords.join(' ');
|
||||||
|
|
||||||
|
if (opts.site) {
|
||||||
|
const repo = opts.commsRepo ?? defaultCommsRepo();
|
||||||
|
const tool = join(repo, 'tools', 'comms-send.sh');
|
||||||
|
try {
|
||||||
|
accessSync(tool, constants.X_OK);
|
||||||
|
} catch {
|
||||||
|
console.error(
|
||||||
|
`mosaic comms: fleet-comms sender not found (expected ${tool}). ` +
|
||||||
|
'Clone the fleet-comms repo or point --comms-repo at it.',
|
||||||
|
);
|
||||||
|
process.exitCode = 127;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!process.env['MOSAIC_AGENT_NAME']) {
|
||||||
|
console.error(
|
||||||
|
'mosaic comms: inter-site sends require MOSAIC_AGENT_NAME (sending identity).',
|
||||||
|
);
|
||||||
|
process.exitCode = 2; // invocation defect: fixable by the caller
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// comms-send.sh locates its working repo via FLEET_COMMS_REPO
|
||||||
|
// (default $HOME/src/fleet-comms); without this, --comms-repo
|
||||||
|
// would select the executable but not the repository it operates
|
||||||
|
// on (codex review of 9c8b6ebf).
|
||||||
|
const env = { ...process.env, FLEET_COMMS_SITE: opts.site, FLEET_COMMS_REPO: repo };
|
||||||
|
const result = spawnSync(tool, fleetCommsSendArgs(opts.site, target, message, opts), {
|
||||||
|
stdio: 'inherit',
|
||||||
|
env,
|
||||||
|
});
|
||||||
|
process.exitCode = result.status ?? 125;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same-host: the brain tool owns validation (bad class -> its rc 3)
|
||||||
|
// and absence (execBrainTool -> 127 with the resolved path).
|
||||||
|
process.exitCode = execBrainTool(
|
||||||
|
home,
|
||||||
|
'tools/tmux/agent-send.sh',
|
||||||
|
tmuxSendArgs(target, message, opts),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
cmd.addHelpText(
|
||||||
|
'after',
|
||||||
|
'\nExit codes pass through. rc=2 means the text reached the pane but is still a draft: NEVER retry (double-send); confirm with tmux capture-pane.',
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { mkdirSync, mkdtempSync, writeFileSync, chmodSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
import { Command } from 'commander';
|
||||||
|
import { afterEach, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { registerQCommand, resolveQuestionTool } from './q.js';
|
||||||
|
import { exitStatusFor, resolveBrainTool } from './brain-dispatch.js';
|
||||||
|
|
||||||
|
describe('resolveBrainTool', () => {
|
||||||
|
const saved = process.env['MOSAIC_BRAIN_HOME'];
|
||||||
|
afterEach(() => {
|
||||||
|
if (saved === undefined) delete process.env['MOSAIC_BRAIN_HOME'];
|
||||||
|
else process.env['MOSAIC_BRAIN_HOME'] = saved;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('joins brain home with the relative tool path', () => {
|
||||||
|
const tmp = mkdtempSync(join(tmpdir(), 'dispatch-resolve-'));
|
||||||
|
process.env['MOSAIC_BRAIN_HOME'] = tmp;
|
||||||
|
expect(resolveBrainTool('/nonexistent/mosaic-home', 'tools/questions/q-new.sh')).toBe(
|
||||||
|
join(tmp, 'tools', 'questions', 'q-new.sh'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resolveQuestionTool', () => {
|
||||||
|
const saved = process.env['MOSAIC_BRAIN_HOME'];
|
||||||
|
afterEach(() => {
|
||||||
|
if (saved === undefined) delete process.env['MOSAIC_BRAIN_HOME'];
|
||||||
|
else process.env['MOSAIC_BRAIN_HOME'] = saved;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps new/render subcommands to their brain tools', () => {
|
||||||
|
const tmp = mkdtempSync(join(tmpdir(), 'q-resolve-'));
|
||||||
|
process.env['MOSAIC_BRAIN_HOME'] = tmp;
|
||||||
|
expect(resolveQuestionTool(tmp, 'new')).toBe(join(tmp, 'tools', 'questions', 'q-new.sh'));
|
||||||
|
expect(resolveQuestionTool(tmp, 'render')).toBe(join(tmp, 'tools', 'questions', 'render.py'));
|
||||||
|
expect(resolveQuestionTool(tmp, 'bogus')).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('registerQCommand usage + dispatch', () => {
|
||||||
|
const saved = process.env['MOSAIC_BRAIN_HOME'];
|
||||||
|
afterEach(() => {
|
||||||
|
if (saved === undefined) delete process.env['MOSAIC_BRAIN_HOME'];
|
||||||
|
else process.env['MOSAIC_BRAIN_HOME'] = saved;
|
||||||
|
process.exitCode = undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exit 2 with the subcommand list when no/unknown subcommand', async () => {
|
||||||
|
process.env['MOSAIC_BRAIN_HOME'] = mkdtempSync(join(tmpdir(), 'q-usage-'));
|
||||||
|
const program = new Command();
|
||||||
|
registerQCommand(program);
|
||||||
|
await program.parseAsync(['q'], { from: 'user' });
|
||||||
|
expect(process.exitCode).toBe(2);
|
||||||
|
process.exitCode = undefined;
|
||||||
|
await program.parseAsync(['q', 'bogus'], { from: 'user' });
|
||||||
|
expect(process.exitCode).toBe(2);
|
||||||
|
process.exitCode = undefined;
|
||||||
|
// Reserved property names must not leak through the record lookup.
|
||||||
|
await program.parseAsync(['q', 'toString'], { from: 'user' });
|
||||||
|
expect(process.exitCode).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('execs the brain tool with pass-through args and exit code', async () => {
|
||||||
|
const tmp = mkdtempSync(join(tmpdir(), 'q-live-'));
|
||||||
|
process.env['MOSAIC_BRAIN_HOME'] = tmp;
|
||||||
|
mkdirSync(join(tmp, 'tools', 'questions'), { recursive: true });
|
||||||
|
const stub = join(tmp, 'tools', 'questions', 'q-new.sh');
|
||||||
|
writeFileSync(stub, '#!/usr/bin/env bash\necho "called with: $*"\nexit 7\n');
|
||||||
|
chmodSync(stub, 0o755);
|
||||||
|
|
||||||
|
const program = new Command();
|
||||||
|
registerQCommand(program);
|
||||||
|
await program.parseAsync(['q', 'new', '--slug', 'x', '--question', 'why'], { from: 'user' });
|
||||||
|
expect(process.exitCode).toBe(7);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('exitStatusFor (shared dispatch contract)', () => {
|
||||||
|
it('maps absent tool to 127', () => {
|
||||||
|
expect(exitStatusFor({ status: 0 }, false)).toBe(127);
|
||||||
|
});
|
||||||
|
it('passes tool status through', () => {
|
||||||
|
expect(exitStatusFor({ status: 7 }, true)).toBe(7);
|
||||||
|
});
|
||||||
|
it('maps signal death to 125', () => {
|
||||||
|
expect(exitStatusFor({ status: null }, true)).toBe(125);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import type { Command } from 'commander';
|
||||||
|
|
||||||
|
import { DEFAULT_MOSAIC_HOME } from '../constants.js';
|
||||||
|
import { execBrainTool, resolveBrainTool } from './brain-dispatch.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `mosaic q` — tracked decision questions (brain tools/questions).
|
||||||
|
* `new` files a question file (one FILE per question, merge-conflict
|
||||||
|
* impossible by construction); `render` regenerates the
|
||||||
|
* docs/OPEN-QUESTIONS.md index (id allocation happens in the renderer).
|
||||||
|
*/
|
||||||
|
const SUBCOMMANDS: Record<string, { path: string; interpreter?: string; help: string }> = {
|
||||||
|
new: {
|
||||||
|
path: 'tools/questions/q-new.sh',
|
||||||
|
help: 'file a question (--question, --slug, --owed-by, ...)',
|
||||||
|
},
|
||||||
|
render: {
|
||||||
|
path: 'tools/questions/render.py',
|
||||||
|
interpreter: 'python3',
|
||||||
|
help: 'regenerate docs/OPEN-QUESTIONS.md (owns Q-id allocation)',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function resolveQuestionTool(mosaicHome: string, sub: string): string | undefined {
|
||||||
|
const entry = SUBCOMMANDS[sub];
|
||||||
|
return entry ? resolveBrainTool(mosaicHome, entry.path) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerQCommand(program: Command): void {
|
||||||
|
const cmd: Command = program
|
||||||
|
.command('q')
|
||||||
|
.description('Tracked decision questions: file and render (brain tools/questions)')
|
||||||
|
.allowUnknownOption()
|
||||||
|
.argument('[args...]', 'subcommand + args passed through to the question tools')
|
||||||
|
.action(async (args: string[], _opts: unknown, command: Command) => {
|
||||||
|
let mosaicHome: string | undefined;
|
||||||
|
for (let anc: Command | null = command; anc; anc = anc.parent) {
|
||||||
|
const v = (anc.opts() as Record<string, string | undefined>)['mosaicHome'];
|
||||||
|
if (v !== undefined) {
|
||||||
|
mosaicHome = v;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const home = mosaicHome ?? DEFAULT_MOSAIC_HOME;
|
||||||
|
|
||||||
|
const sub = args[0];
|
||||||
|
if (!sub || !Object.hasOwn(SUBCOMMANDS, sub)) {
|
||||||
|
console.error('mosaic q: expected a subcommand:');
|
||||||
|
for (const [name, entry] of Object.entries(SUBCOMMANDS)) {
|
||||||
|
console.error(` mosaic q ${name} ${entry.help}`);
|
||||||
|
}
|
||||||
|
process.exitCode = 2; // usage error contract: invocation defect
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const entry = SUBCOMMANDS[sub]!;
|
||||||
|
process.exitCode = execBrainTool(home, entry.path, args.slice(1), entry.interpreter);
|
||||||
|
});
|
||||||
|
|
||||||
|
cmd.addHelpText(
|
||||||
|
'after',
|
||||||
|
'\nEverything after the subcommand is passed through verbatim (args, output, exit code).',
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { mkdirSync, mkdtempSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
import { Command } from 'commander';
|
||||||
|
import { afterEach, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { exitStatusFor, registerWatchCommand, resolveAgentWatchTool } from './watch.js';
|
||||||
|
|
||||||
|
// The dispatch command execs a real process with inherited stdio; the spec
|
||||||
|
// covers the pure resolution and exit-mapping surfaces plus the absent-tool
|
||||||
|
// path (which exits without spawning). Live pass-through is exercised by the
|
||||||
|
// fleet smoke test against the real brain tool.
|
||||||
|
|
||||||
|
describe('resolveAgentWatchTool', () => {
|
||||||
|
const saved = process.env['MOSAIC_BRAIN_HOME'];
|
||||||
|
afterEach(() => {
|
||||||
|
if (saved === undefined) delete process.env['MOSAIC_BRAIN_HOME'];
|
||||||
|
else process.env['MOSAIC_BRAIN_HOME'] = saved;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('honors MOSAIC_BRAIN_HOME over the canonical brain', () => {
|
||||||
|
const tmp = mkdtempSync(join(tmpdir(), 'watch-resolve-'));
|
||||||
|
process.env['MOSAIC_BRAIN_HOME'] = tmp;
|
||||||
|
expect(resolveAgentWatchTool('/nonexistent/mosaic-home')).toBe(
|
||||||
|
join(tmp, 'tools', 'agent-watch', 'agent-watch.sh'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves inside the brain tools tree', () => {
|
||||||
|
const tmp = mkdtempSync(join(tmpdir(), 'watch-resolve-'));
|
||||||
|
process.env['MOSAIC_BRAIN_HOME'] = tmp;
|
||||||
|
const tool = resolveAgentWatchTool(tmp);
|
||||||
|
expect(tool.endsWith(join('tools', 'agent-watch', 'agent-watch.sh'))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('exitStatusFor', () => {
|
||||||
|
it('maps absent tool to 127', () => {
|
||||||
|
expect(exitStatusFor({ status: 0 }, false)).toBe(127);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes the tool exit status through', () => {
|
||||||
|
expect(exitStatusFor({ status: 2 }, true)).toBe(2);
|
||||||
|
expect(exitStatusFor({ status: 78 }, true)).toBe(78);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps signal death / null status to 125', () => {
|
||||||
|
expect(exitStatusFor({ status: null }, true)).toBe(125);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('registerWatchCommand absent-tool path', () => {
|
||||||
|
const saved = process.env['MOSAIC_BRAIN_HOME'];
|
||||||
|
afterEach(() => {
|
||||||
|
if (saved === undefined) delete process.env['MOSAIC_BRAIN_HOME'];
|
||||||
|
else process.env['MOSAIC_BRAIN_HOME'] = saved;
|
||||||
|
process.exitCode = undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets exitCode 127 with the resolved path when the tool is missing', async () => {
|
||||||
|
const tmp = mkdtempSync(join(tmpdir(), 'watch-missing-'));
|
||||||
|
// The suite directory exists but the tool file does not.
|
||||||
|
mkdirSync(join(tmp, 'tools', 'agent-watch'), { recursive: true });
|
||||||
|
process.env['MOSAIC_BRAIN_HOME'] = tmp;
|
||||||
|
|
||||||
|
const program = new Command();
|
||||||
|
registerWatchCommand(program);
|
||||||
|
await program.parseAsync(['watch', 'list'], { from: 'user' });
|
||||||
|
expect(process.exitCode).toBe(127);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import type { Command } from 'commander';
|
||||||
|
|
||||||
|
import { DEFAULT_MOSAIC_HOME } from '../constants.js';
|
||||||
|
import {
|
||||||
|
brainToolExists,
|
||||||
|
execBrainTool,
|
||||||
|
exitStatusFor,
|
||||||
|
resolveBrainTool,
|
||||||
|
} from './brain-dispatch.js';
|
||||||
|
|
||||||
|
export { exitStatusFor };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `mosaic watch` — dispatch to the brain's agent-watch suite.
|
||||||
|
* See brain-dispatch.ts for the architecture and pass-through contract.
|
||||||
|
*/
|
||||||
|
export function resolveAgentWatchTool(mosaicHome: string): string {
|
||||||
|
return resolveBrainTool(mosaicHome, 'tools/agent-watch/agent-watch.sh');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerWatchCommand(program: Command): void {
|
||||||
|
const cmd: Command = program
|
||||||
|
.command('watch')
|
||||||
|
.description('Wake-me-when watchers (agent-watch): start, list, stop')
|
||||||
|
// allowUnknownOption + variadic = full ordered pass-through: unknown
|
||||||
|
// options (--name, --when, ...) and their values land in args verbatim
|
||||||
|
// (commander 13 measured behavior), so the tool owns its own flag
|
||||||
|
// surface without the CLI needing passThroughOptions (which would
|
||||||
|
// force enablePositionalOptions fleet-wide on the root program).
|
||||||
|
.allowUnknownOption()
|
||||||
|
// The tool owns help too: without this, commander would intercept
|
||||||
|
// --help and answer with wrapper help instead of agent-watch's own
|
||||||
|
// (codex review of 18f3dd49).
|
||||||
|
.helpOption(false)
|
||||||
|
.argument('[args...]', 'args passed through to agent-watch.sh')
|
||||||
|
.action(async (args: string[], _opts: unknown, command: Command) => {
|
||||||
|
// --mosaic-home is not global in this CLI; walk parents for it and
|
||||||
|
// fall back to the default. MOSAIC_BRAIN_HOME (seat launchers export
|
||||||
|
// it) wins inside resolveBrainHome regardless.
|
||||||
|
let mosaicHome: string | undefined;
|
||||||
|
for (let anc: Command | null = command; anc; anc = anc.parent) {
|
||||||
|
const v = (anc.opts() as Record<string, string | undefined>)['mosaicHome'];
|
||||||
|
if (v !== undefined) {
|
||||||
|
mosaicHome = v;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const home = mosaicHome ?? DEFAULT_MOSAIC_HOME;
|
||||||
|
|
||||||
|
const tool = resolveAgentWatchTool(home);
|
||||||
|
if (!brainToolExists(tool)) {
|
||||||
|
console.error(
|
||||||
|
`mosaic watch: agent-watch not found (expected ${tool}). ` +
|
||||||
|
'The watcher suite lives in the brain tree under tools/agent-watch/; ' +
|
||||||
|
'check MOSAIC_BRAIN_HOME or the brain checkout.',
|
||||||
|
);
|
||||||
|
process.exitCode = 127;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
process.exitCode = execBrainTool(home, 'tools/agent-watch/agent-watch.sh', args);
|
||||||
|
});
|
||||||
|
|
||||||
|
cmd.addHelpText(
|
||||||
|
'after',
|
||||||
|
'\nEverything after `mosaic watch` is passed through to agent-watch.sh verbatim (args, output, exit code).',
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user