Align reviewer runtime and Telegram notifications
This commit is contained in:
99
skills/reviewer-runtime/notify-telegram.sh
Executable file
99
skills/reviewer-runtime/notify-telegram.sh
Executable file
@@ -0,0 +1,99 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
DEFAULT_API_BASE_URL="https://api.telegram.org"
|
||||
DEFAULT_PARSE_MODE="HTML"
|
||||
MAX_MESSAGE_LENGTH=4096
|
||||
|
||||
BOT_TOKEN=${TELEGRAM_BOT_TOKEN:-}
|
||||
CHAT_ID=${TELEGRAM_CHAT_ID:-}
|
||||
API_BASE_URL=${TELEGRAM_API_BASE_URL:-$DEFAULT_API_BASE_URL}
|
||||
PARSE_MODE=${TELEGRAM_PARSE_MODE:-$DEFAULT_PARSE_MODE}
|
||||
MESSAGE=""
|
||||
MESSAGE_FILE=""
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage:
|
||||
notify-telegram.sh --message <text> [--bot-token <token>] [--chat-id <id>] [--api-base-url <url>]
|
||||
notify-telegram.sh --message-file <path> [--bot-token <token>] [--chat-id <id>] [--api-base-url <url>]
|
||||
|
||||
Environment fallbacks:
|
||||
TELEGRAM_BOT_TOKEN
|
||||
TELEGRAM_CHAT_ID
|
||||
TELEGRAM_API_BASE_URL
|
||||
TELEGRAM_PARSE_MODE
|
||||
EOF
|
||||
}
|
||||
|
||||
fail_usage() {
|
||||
echo "Error: $*" >&2
|
||||
usage >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
parse_args() {
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--bot-token)
|
||||
BOT_TOKEN=${2:-}
|
||||
shift 2
|
||||
;;
|
||||
--chat-id)
|
||||
CHAT_ID=${2:-}
|
||||
shift 2
|
||||
;;
|
||||
--api-base-url)
|
||||
API_BASE_URL=${2:-}
|
||||
shift 2
|
||||
;;
|
||||
--message)
|
||||
MESSAGE=${2:-}
|
||||
shift 2
|
||||
;;
|
||||
--message-file)
|
||||
MESSAGE_FILE=${2:-}
|
||||
shift 2
|
||||
;;
|
||||
--help|-h)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
fail_usage "unknown argument: $1"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -n "$MESSAGE" && -n "$MESSAGE_FILE" ]]; then
|
||||
fail_usage "use either --message or --message-file, not both"
|
||||
fi
|
||||
|
||||
if [[ -n "$MESSAGE_FILE" ]]; then
|
||||
[[ -r "$MESSAGE_FILE" ]] || fail_usage "message file is not readable: $MESSAGE_FILE"
|
||||
MESSAGE=$(<"$MESSAGE_FILE")
|
||||
fi
|
||||
|
||||
[[ -n "$MESSAGE" ]] || fail_usage "message is required"
|
||||
[[ -n "$BOT_TOKEN" ]] || fail_usage "bot token is required (use --bot-token or TELEGRAM_BOT_TOKEN)"
|
||||
[[ -n "$CHAT_ID" ]] || fail_usage "chat id is required (use --chat-id or TELEGRAM_CHAT_ID)"
|
||||
command -v curl >/dev/null 2>&1 || fail_usage "curl is required"
|
||||
|
||||
if [[ ${#MESSAGE} -gt "$MAX_MESSAGE_LENGTH" ]]; then
|
||||
MESSAGE=${MESSAGE:0:$MAX_MESSAGE_LENGTH}
|
||||
fi
|
||||
}
|
||||
|
||||
main() {
|
||||
parse_args "$@"
|
||||
|
||||
curl -fsS -X POST \
|
||||
"${API_BASE_URL%/}/bot${BOT_TOKEN}/sendMessage" \
|
||||
--data-urlencode "chat_id=${CHAT_ID}" \
|
||||
--data-urlencode "text=${MESSAGE}" \
|
||||
--data-urlencode "parse_mode=${PARSE_MODE}" \
|
||||
--data-urlencode "disable_web_page_preview=true" \
|
||||
>/dev/null
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -2,6 +2,7 @@
|
||||
set -euo pipefail
|
||||
|
||||
DEFAULT_POLL_SECONDS=10
|
||||
DEFAULT_HEARTBEAT_SECONDS=60
|
||||
DEFAULT_SOFT_TIMEOUT_SECONDS=600
|
||||
DEFAULT_STALL_WARNING_SECONDS=300
|
||||
DEFAULT_HARD_TIMEOUT_SECONDS=1800
|
||||
@@ -12,7 +13,9 @@ COMMAND_FILE=""
|
||||
STDOUT_FILE=""
|
||||
STDERR_FILE=""
|
||||
STATUS_FILE=""
|
||||
SUCCESS_FILES=()
|
||||
POLL_SECONDS=$DEFAULT_POLL_SECONDS
|
||||
HEARTBEAT_SECONDS=$DEFAULT_HEARTBEAT_SECONDS
|
||||
SOFT_TIMEOUT_SECONDS=$DEFAULT_SOFT_TIMEOUT_SECONDS
|
||||
STALL_WARNING_SECONDS=$DEFAULT_STALL_WARNING_SECONDS
|
||||
HARD_TIMEOUT_SECONDS=$DEFAULT_HARD_TIMEOUT_SECONDS
|
||||
@@ -29,7 +32,9 @@ Usage:
|
||||
--stdout-file <path> \
|
||||
--stderr-file <path> \
|
||||
--status-file <path> \
|
||||
[--success-file <path>] \
|
||||
[--poll-seconds <int>] \
|
||||
[--heartbeat-seconds <int>] \
|
||||
[--soft-timeout-seconds <int>] \
|
||||
[--stall-warning-seconds <int>] \
|
||||
[--hard-timeout-seconds <int>]
|
||||
@@ -55,6 +60,25 @@ escape_note() {
|
||||
printf '%s' "$note"
|
||||
}
|
||||
|
||||
join_success_files() {
|
||||
if [[ ${#SUCCESS_FILES[@]} -eq 0 ]]; then
|
||||
printf ''
|
||||
return 0
|
||||
fi
|
||||
|
||||
local joined=""
|
||||
local path
|
||||
|
||||
for path in "${SUCCESS_FILES[@]}"; do
|
||||
if [[ -n "$joined" ]]; then
|
||||
joined+=", "
|
||||
fi
|
||||
joined+="$path"
|
||||
done
|
||||
|
||||
printf '%s' "$joined"
|
||||
}
|
||||
|
||||
iso_timestamp() {
|
||||
date -u +"%Y-%m-%dT%H:%M:%SZ"
|
||||
}
|
||||
@@ -147,10 +171,18 @@ parse_args() {
|
||||
STATUS_FILE=${2:-}
|
||||
shift 2
|
||||
;;
|
||||
--success-file)
|
||||
SUCCESS_FILES+=("${2:-}")
|
||||
shift 2
|
||||
;;
|
||||
--poll-seconds)
|
||||
POLL_SECONDS=${2:-}
|
||||
shift 2
|
||||
;;
|
||||
--heartbeat-seconds)
|
||||
HEARTBEAT_SECONDS=${2:-}
|
||||
shift 2
|
||||
;;
|
||||
--soft-timeout-seconds)
|
||||
SOFT_TIMEOUT_SECONDS=${2:-}
|
||||
shift 2
|
||||
@@ -179,11 +211,13 @@ parse_args() {
|
||||
[[ -n "$STATUS_FILE" ]] || fail_usage "--status-file is required"
|
||||
|
||||
require_integer "poll-seconds" "$POLL_SECONDS"
|
||||
require_integer "heartbeat-seconds" "$HEARTBEAT_SECONDS"
|
||||
require_integer "soft-timeout-seconds" "$SOFT_TIMEOUT_SECONDS"
|
||||
require_integer "stall-warning-seconds" "$STALL_WARNING_SECONDS"
|
||||
require_integer "hard-timeout-seconds" "$HARD_TIMEOUT_SECONDS"
|
||||
|
||||
[[ "$POLL_SECONDS" -gt 0 ]] || fail_usage "poll-seconds must be > 0"
|
||||
[[ "$HEARTBEAT_SECONDS" -gt 0 ]] || fail_usage "heartbeat-seconds must be > 0"
|
||||
[[ "$SOFT_TIMEOUT_SECONDS" -gt 0 ]] || fail_usage "soft-timeout-seconds must be > 0"
|
||||
[[ "$STALL_WARNING_SECONDS" -gt 0 ]] || fail_usage "stall-warning-seconds must be > 0"
|
||||
[[ "$HARD_TIMEOUT_SECONDS" -gt 0 ]] || fail_usage "hard-timeout-seconds must be > 0"
|
||||
@@ -227,8 +261,10 @@ main() {
|
||||
local last_stdout_bytes=0
|
||||
local last_stderr_bytes=0
|
||||
local last_output_change_time=$START_TIME
|
||||
local last_heartbeat_time=$START_TIME
|
||||
local soft_timeout_logged=0
|
||||
local stall_warning_logged=0
|
||||
local heartbeat_count=0
|
||||
|
||||
while kill -0 "$CHILD_PID" 2>/dev/null; do
|
||||
sleep "$POLL_SECONDS"
|
||||
@@ -239,6 +275,12 @@ main() {
|
||||
stdout_bytes=$(file_bytes "$STDOUT_FILE")
|
||||
stderr_bytes=$(file_bytes "$STDERR_FILE")
|
||||
|
||||
if [[ $((now - last_heartbeat_time)) -ge "$HEARTBEAT_SECONDS" ]]; then
|
||||
heartbeat_count=$((heartbeat_count + 1))
|
||||
append_status info in-progress "In progress ${heartbeat_count}"
|
||||
last_heartbeat_time=$now
|
||||
fi
|
||||
|
||||
if [[ "$stdout_bytes" -ne "$last_stdout_bytes" || "$stderr_bytes" -ne "$last_stderr_bytes" ]]; then
|
||||
last_output_change_time=$now
|
||||
stall_warning_logged=0
|
||||
@@ -285,6 +327,7 @@ main() {
|
||||
trap - EXIT
|
||||
|
||||
local final_stdout_bytes final_stderr_bytes
|
||||
local success_file success_bytes
|
||||
final_stdout_bytes=$(file_bytes "$STDOUT_FILE")
|
||||
final_stderr_bytes=$(file_bytes "$STDERR_FILE")
|
||||
|
||||
@@ -294,6 +337,16 @@ main() {
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ ${#SUCCESS_FILES[@]} -gt 0 ]]; then
|
||||
for success_file in "${SUCCESS_FILES[@]}"; do
|
||||
success_bytes=$(file_bytes "$success_file")
|
||||
if [[ "$success_bytes" -gt 0 ]]; then
|
||||
append_status info completed "reviewer completed successfully via success file $(join_success_files)"
|
||||
exit 0
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
append_status error completed-empty-output "reviewer exited successfully with empty stdout"
|
||||
exit "$EXIT_COMPLETED_EMPTY_OUTPUT"
|
||||
fi
|
||||
|
||||
@@ -34,6 +34,9 @@ check_skill_file() {
|
||||
assert_contains "$file" '$(cat /tmp/'
|
||||
assert_contains "$file" "--strict-mcp-config"
|
||||
assert_contains "$file" "--setting-sources user"
|
||||
assert_contains "$file" "### P0"
|
||||
assert_contains "$file" "In progress N"
|
||||
assert_contains "$file" "notify-telegram.sh"
|
||||
assert_not_contains "$file" "--allowedTools Read"
|
||||
}
|
||||
|
||||
|
||||
82
skills/reviewer-runtime/tests/smoke-test.sh
Normal file → Executable file
82
skills/reviewer-runtime/tests/smoke-test.sh
Normal file → Executable file
@@ -66,6 +66,28 @@ run_helper() {
|
||||
return "$exit_code"
|
||||
}
|
||||
|
||||
run_helper_allowing_success_file() {
|
||||
local command_file=$1
|
||||
local stdout_file=$2
|
||||
local stderr_file=$3
|
||||
local status_file=$4
|
||||
local success_file=$5
|
||||
shift 5
|
||||
|
||||
set +e
|
||||
"$HELPER_PATH" \
|
||||
--command-file "$command_file" \
|
||||
--stdout-file "$stdout_file" \
|
||||
--stderr-file "$stderr_file" \
|
||||
--status-file "$status_file" \
|
||||
--success-file "$success_file" \
|
||||
"$@"
|
||||
local exit_code=$?
|
||||
set -e
|
||||
|
||||
return "$exit_code"
|
||||
}
|
||||
|
||||
test_delayed_success() {
|
||||
local dir=$1
|
||||
local command_file=$dir/delayed-success.sh
|
||||
@@ -120,6 +142,36 @@ printf "completed after soft timeout\n"
|
||||
assert_file_contains "$status_file" "state=completed"
|
||||
}
|
||||
|
||||
test_in_progress_heartbeats() {
|
||||
local dir=$1
|
||||
local command_file=$dir/in-progress-heartbeats.sh
|
||||
local stdout_file=$dir/in-progress-heartbeats.stdout
|
||||
local stderr_file=$dir/in-progress-heartbeats.stderr
|
||||
local status_file=$dir/in-progress-heartbeats.status
|
||||
|
||||
make_command "$command_file" '
|
||||
sleep 3
|
||||
printf "finished with heartbeat coverage\n"
|
||||
'
|
||||
|
||||
if run_helper "$command_file" "$stdout_file" "$stderr_file" "$status_file" \
|
||||
--poll-seconds 1 \
|
||||
--heartbeat-seconds 1 \
|
||||
--soft-timeout-seconds 5 \
|
||||
--stall-warning-seconds 4 \
|
||||
--hard-timeout-seconds 10; then
|
||||
local exit_code=0
|
||||
else
|
||||
local exit_code=$?
|
||||
fi
|
||||
|
||||
assert_exit_code "$exit_code" 0
|
||||
assert_file_contains "$stdout_file" "finished with heartbeat coverage"
|
||||
assert_file_contains "$status_file" "state=in-progress"
|
||||
assert_file_contains "$status_file" "In progress 1"
|
||||
assert_file_contains "$status_file" "In progress 2"
|
||||
}
|
||||
|
||||
test_nonzero_failure() {
|
||||
local dir=$1
|
||||
local command_file=$dir/nonzero-failure.sh
|
||||
@@ -173,6 +225,34 @@ exit 0
|
||||
assert_file_contains "$status_file" "state=completed-empty-output"
|
||||
}
|
||||
|
||||
test_success_file_allows_empty_stdout() {
|
||||
local dir=$1
|
||||
local command_file=$dir/success-file.sh
|
||||
local stdout_file=$dir/success-file.stdout
|
||||
local stderr_file=$dir/success-file.stderr
|
||||
local status_file=$dir/success-file.status
|
||||
local success_file=$dir/review-output.md
|
||||
|
||||
make_command "$command_file" "
|
||||
printf 'review body from redirected file\\n' > \"$success_file\"
|
||||
exit 0
|
||||
"
|
||||
|
||||
if run_helper_allowing_success_file "$command_file" "$stdout_file" "$stderr_file" "$status_file" "$success_file" \
|
||||
--poll-seconds 1 \
|
||||
--soft-timeout-seconds 5 \
|
||||
--stall-warning-seconds 3 \
|
||||
--hard-timeout-seconds 10; then
|
||||
local exit_code=0
|
||||
else
|
||||
local exit_code=$?
|
||||
fi
|
||||
|
||||
assert_exit_code "$exit_code" 0
|
||||
assert_file_contains "$success_file" "review body from redirected file"
|
||||
assert_file_contains "$status_file" "state=completed"
|
||||
}
|
||||
|
||||
test_signal_cleanup() {
|
||||
local dir=$1
|
||||
local command_file=$dir/signal-child.sh
|
||||
@@ -252,8 +332,10 @@ main() {
|
||||
|
||||
test_delayed_success "$tmp_dir"
|
||||
test_soft_timeout_continues "$tmp_dir"
|
||||
test_in_progress_heartbeats "$tmp_dir"
|
||||
test_nonzero_failure "$tmp_dir"
|
||||
test_empty_output_is_terminal "$tmp_dir"
|
||||
test_success_file_allows_empty_stdout "$tmp_dir"
|
||||
test_signal_cleanup "$tmp_dir"
|
||||
test_hard_timeout_escalation "$tmp_dir"
|
||||
|
||||
|
||||
158
skills/reviewer-runtime/tests/telegram-notify-test.sh
Executable file
158
skills/reviewer-runtime/tests/telegram-notify-test.sh
Executable file
@@ -0,0 +1,158 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
||||
HELPER_PATH=$(cd "$SCRIPT_DIR/.." && pwd)/notify-telegram.sh
|
||||
|
||||
fail() {
|
||||
echo "FAIL: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
assert_equals() {
|
||||
local actual=$1
|
||||
local expected=$2
|
||||
if [[ "$actual" != "$expected" ]]; then
|
||||
fail "expected '$expected', got '$actual'"
|
||||
fi
|
||||
}
|
||||
|
||||
assert_file_contains() {
|
||||
local file=$1
|
||||
local pattern=$2
|
||||
if ! grep -qF -- "$pattern" "$file"; then
|
||||
echo "--- $file ---" >&2
|
||||
sed -n '1,200p' "$file" >&2 || true
|
||||
fail "expected '$pattern' in $file"
|
||||
fi
|
||||
}
|
||||
|
||||
capture_curl() {
|
||||
local bin_dir=$1
|
||||
local curl_args_file=$2
|
||||
|
||||
mkdir -p "$bin_dir"
|
||||
cat >"$bin_dir/curl" <<EOF
|
||||
#!/usr/bin/env bash
|
||||
printf '%s\n' "\$@" >"$curl_args_file"
|
||||
printf '{"ok":true}\n'
|
||||
EOF
|
||||
chmod +x "$bin_dir/curl"
|
||||
}
|
||||
|
||||
read_curl_value() {
|
||||
local file=$1
|
||||
local key=$2
|
||||
|
||||
awk -v prefix="${key}=" 'index($0, prefix) == 1 { print substr($0, length(prefix) + 1); exit }' "$file"
|
||||
}
|
||||
|
||||
test_missing_credentials() {
|
||||
local tmp_dir=$1
|
||||
local output_file=$tmp_dir/missing-credentials.out
|
||||
|
||||
set +e
|
||||
env -u TELEGRAM_BOT_TOKEN -u TELEGRAM_CHAT_ID \
|
||||
"$HELPER_PATH" --message "hello" >"$output_file" 2>&1
|
||||
local exit_code=$?
|
||||
set -e
|
||||
|
||||
[[ "$exit_code" -eq 2 ]] || fail "expected exit code 2 for missing credentials, got $exit_code"
|
||||
assert_file_contains "$output_file" "bot token is required"
|
||||
}
|
||||
|
||||
test_rejects_message_and_message_file_together() {
|
||||
local tmp_dir=$1
|
||||
local message_file=$tmp_dir/message.txt
|
||||
local output_file=$tmp_dir/message-and-file.out
|
||||
|
||||
printf 'hello from file\n' >"$message_file"
|
||||
|
||||
set +e
|
||||
TELEGRAM_BOT_TOKEN=test-token \
|
||||
TELEGRAM_CHAT_ID=123456 \
|
||||
"$HELPER_PATH" --message "hello" --message-file "$message_file" >"$output_file" 2>&1
|
||||
local exit_code=$?
|
||||
set -e
|
||||
|
||||
[[ "$exit_code" -eq 2 ]] || fail "expected exit code 2 for mutually exclusive arguments, got $exit_code"
|
||||
assert_file_contains "$output_file" "use either --message or --message-file, not both"
|
||||
}
|
||||
|
||||
test_successful_request() {
|
||||
local tmp_dir=$1
|
||||
local bin_dir=$tmp_dir/bin
|
||||
local curl_args_file=$tmp_dir/curl-args.txt
|
||||
capture_curl "$bin_dir" "$curl_args_file"
|
||||
|
||||
PATH="$bin_dir:$PATH" \
|
||||
TELEGRAM_BOT_TOKEN=test-token \
|
||||
TELEGRAM_CHAT_ID=123456 \
|
||||
"$HELPER_PATH" --message "Plan completed"
|
||||
|
||||
assert_file_contains "$curl_args_file" "https://api.telegram.org/bottest-token/sendMessage"
|
||||
assert_file_contains "$curl_args_file" "chat_id=123456"
|
||||
assert_file_contains "$curl_args_file" "text=Plan completed"
|
||||
assert_file_contains "$curl_args_file" "disable_web_page_preview=true"
|
||||
assert_file_contains "$curl_args_file" "parse_mode=HTML"
|
||||
}
|
||||
|
||||
test_message_file_and_custom_api_base() {
|
||||
local tmp_dir=$1
|
||||
local bin_dir=$tmp_dir/bin-message-file
|
||||
local curl_args_file=$tmp_dir/curl-message-file.txt
|
||||
local message_file=$tmp_dir/telegram-message.txt
|
||||
capture_curl "$bin_dir" "$curl_args_file"
|
||||
|
||||
printf 'Plan completed from file\n' >"$message_file"
|
||||
|
||||
PATH="$bin_dir:$PATH" \
|
||||
TELEGRAM_BOT_TOKEN=test-token \
|
||||
TELEGRAM_CHAT_ID=654321 \
|
||||
"$HELPER_PATH" \
|
||||
--message-file "$message_file" \
|
||||
--api-base-url "https://telegram.example.test/custom"
|
||||
|
||||
assert_file_contains "$curl_args_file" "https://telegram.example.test/custom/bottest-token/sendMessage"
|
||||
assert_file_contains "$curl_args_file" "chat_id=654321"
|
||||
assert_file_contains "$curl_args_file" "text=Plan completed from file"
|
||||
}
|
||||
|
||||
test_truncates_long_message() {
|
||||
local tmp_dir=$1
|
||||
local bin_dir=$tmp_dir/bin-truncate
|
||||
local curl_args_file=$tmp_dir/curl-truncate.txt
|
||||
local long_message_file=$tmp_dir/long-message.txt
|
||||
local truncated_message
|
||||
capture_curl "$bin_dir" "$curl_args_file"
|
||||
|
||||
python3 - <<'PY' >"$long_message_file"
|
||||
print("A" * 5000, end="")
|
||||
PY
|
||||
|
||||
PATH="$bin_dir:$PATH" \
|
||||
TELEGRAM_BOT_TOKEN=test-token \
|
||||
TELEGRAM_CHAT_ID=123456 \
|
||||
"$HELPER_PATH" --message-file "$long_message_file"
|
||||
|
||||
truncated_message=$(read_curl_value "$curl_args_file" "text")
|
||||
assert_equals "${#truncated_message}" "4096"
|
||||
}
|
||||
|
||||
main() {
|
||||
[[ -x "$HELPER_PATH" ]] || fail "helper is not executable: $HELPER_PATH"
|
||||
|
||||
local tmp_dir
|
||||
tmp_dir=$(mktemp -d)
|
||||
trap "rm -rf '$tmp_dir'" EXIT
|
||||
|
||||
test_missing_credentials "$tmp_dir"
|
||||
test_rejects_message_and_message_file_together "$tmp_dir"
|
||||
test_successful_request "$tmp_dir"
|
||||
test_message_file_and_custom_api_base "$tmp_dir"
|
||||
test_truncates_long_message "$tmp_dir"
|
||||
|
||||
echo "PASS: telegram notifier tests"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
Reference in New Issue
Block a user