Files
kandev/scripts/pr-state.test.sh
T
2026-06-11 22:27:06 +01:00

376 lines
11 KiB
Bash

#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
SCRIPT="$ROOT_DIR/scripts/pr-state"
pass() {
printf 'ok - %s\n' "$1"
}
fail() {
printf 'not ok - %s\n' "$1" >&2
exit 1
}
TMP_DIRS=()
cleanup() {
if [ "${#TMP_DIRS[@]}" -gt 0 ]; then
rm -rf "${TMP_DIRS[@]}"
fi
}
trap cleanup EXIT
make_tmp_dir() {
local __out=$1
local __tmp
__tmp="$(mktemp -d)"
TMP_DIRS+=("$__tmp")
printf -v "$__out" '%s' "$__tmp"
}
assert_jq() {
local name=$1
local expr=$2
local json=$3
jq -e "$expr" <<<"$json" >/dev/null || fail "$name"
}
make_mock_gh() {
local dir=$1
mkdir -p "$dir"
cat >"$dir/gh" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
if [[ "${GH_FAIL_REVIEWS:-0}" == "1" && "$*" == *"pulls/123/reviews"* ]]; then
echo "reviews api failed" >&2
exit 1
fi
if [[ "${GH_FAIL_GRAPHQL:-0}" == "1" && "$1" == "api" && "$2" == "graphql" ]]; then
echo "graphql failed" >&2
exit 1
fi
if [[ "${GH_FAIL_PR_VIEW:-0}" == "1" && "$1" == "pr" && "$2" == "view" ]]; then
echo "pr view failed" >&2
exit 1
fi
if [[ "${GH_FAIL_REPO:-0}" == "1" && "$1" == "repo" && "$2" == "view" ]]; then
echo "repo view failed" >&2
exit 1
fi
if [[ "$1" == "repo" && "$2" == "view" ]]; then
printf '{"owner":{"login":"kdlbs"},"name":"kandev"}\n'
exit 0
fi
if [[ "$1" == "pr" && "$2" == "view" && "$4" == "--json" ]]; then
if [[ "${GH_NO_PR_URL:-0}" == "1" ]]; then
pr_url='null'
else
pr_url='"https://github.com/kdlbs/kandev/pull/123"'
fi
cat <<JSON
{
"number": 123,
"headRefName": "feat/pr-state",
"url": $pr_url,
"comments": [
{
"author": { "login": "coderabbitai" },
"body": "<!-- walkthrough_start -->"
},
{
"author": { "login": "github-actions" },
"body": "**Claude finished review**\\n| Blocker | 1 |\\n| Suggestion | 2 |\\n**Verdict:** Ready with suggestions"
}
],
"statusCheckRollup": [
{
"__typename": "CheckRun",
"name": "web lint",
"status": "COMPLETED",
"conclusion": "SUCCESS",
"detailsUrl": "https://github.com/kdlbs/kandev/actions/runs/27340000001/job/55150000001"
},
{
"__typename": "CheckRun",
"name": "e2e",
"status": "COMPLETED",
"conclusion": "FAILURE",
"detailsUrl": "https://github.com/kdlbs/kandev/actions/runs/27340000002/job/55150000002",
"checkSuite": {
"workflowRun": {
"workflow": {
"name": "CI"
}
}
}
},
{
"__typename": "CheckRun",
"name": "claude-review",
"status": "IN_PROGRESS",
"conclusion": "",
"detailsUrl": "https://github.com/kdlbs/kandev/actions/runs/27340000003/job/55150000003"
}
]
}
JSON
exit 0
fi
if [[ "$1" == "api" && "$2" == "--paginate" && "$3" == "-X" && "$4" == "GET" && "$5" == "repos/kdlbs/kandev/pulls/123/reviews" ]]; then
printf '%s\n' '[
{ "user": { "login": "greptile-apps[bot]" } }
]'
printf '%s\n' '[
{ "user": { "login": "cubic-dev-ai[bot]" } }
]'
exit 0
fi
if [[ "$1" == "api" && "$2" == "graphql" ]]; then
if [[ "${GH_GRAPHQL_TWO_PAGES:-0}" == "1" && "$*" != *"cursor=CURSOR1"* ]]; then
printf '%s\n' '{
"data": {
"repository": {
"pullRequest": {
"reviewThreads": {
"pageInfo": {
"hasNextPage": true,
"endCursor": "CURSOR1"
},
"nodes": [
{
"id": "PRRT_1",
"isResolved": false,
"path": "apps/web/file.ts",
"comments": {
"nodes": [
{
"databaseId": 111,
"author": { "login": "greptile-apps[bot]" },
"body": "Please rename this helper"
}
]
}
}
]
}
}
}
}
}'
exit 0
fi
if [[ "${GH_GRAPHQL_TWO_PAGES:-0}" == "1" && "$*" == *"cursor=CURSOR1"* ]]; then
printf '%s\n' '{
"data": {
"repository": {
"pullRequest": {
"reviewThreads": {
"pageInfo": {
"hasNextPage": false,
"endCursor": null
},
"nodes": [
{
"id": "PRRT_2",
"isResolved": true,
"path": "apps/web/other.ts",
"comments": {
"nodes": [
{
"databaseId": 222,
"author": { "login": "cubic-dev-ai[bot]" },
"body": "resolved"
}
]
}
}
]
}
}
}
}
}'
exit 0
fi
printf '%s\n' '{
"data": {
"repository": {
"pullRequest": {
"reviewThreads": {
"pageInfo": {
"hasNextPage": false,
"endCursor": null
},
"nodes": [
{
"id": "PRRT_1",
"isResolved": false,
"path": "apps/web/file.ts",
"comments": {
"nodes": [
{
"databaseId": 111,
"author": { "login": "greptile-apps[bot]" },
"body": "Please rename this helper"
}
]
}
},
{
"id": "PRRT_2",
"isResolved": true,
"path": "apps/web/other.ts",
"comments": {
"nodes": [
{
"databaseId": 222,
"author": { "login": "cubic-dev-ai[bot]" },
"body": "resolved"
}
]
}
}
]
}
}
}
}
}'
exit 0
fi
echo "unexpected gh args: $*" >&2
exit 1
EOF
chmod +x "$dir/gh"
}
test_snapshot_happy_path() {
local tmp
make_tmp_dir tmp
make_mock_gh "$tmp/bin"
local json
PATH="$tmp/bin:$PATH" "$SCRIPT" 123 >"$tmp/out.json"
json="$(<"$tmp/out.json")"
assert_jq "pr number" '.pr.number == 123' "$json"
assert_jq "branch" '.pr.branch == "feat/pr-state"' "$json"
assert_jq "checks count" '.checks | length == 3' "$json"
assert_jq "failed check preserved" '.checks[] | select(.name == "e2e") | .conclusion == "failure"' "$json"
assert_jq "check run id" '.checks[] | select(.name == "e2e") | .run_id == "27340000002"' "$json"
assert_jq "nested workflow name" '.checks[] | select(.name == "e2e") | .workflow == "CI"' "$json"
assert_jq "threads count" '.review_threads | length == 2' "$json"
assert_jq "unresolved count" '.unresolved_review_thread_count == 1' "$json"
assert_jq "thread comment id" '.review_threads[] | select(.thread_id == "PRRT_1") | .comment_id == 111' "$json"
assert_jq "thread resolved field" '.review_threads[] | select(.thread_id == "PRRT_2") | .is_resolved == true' "$json"
assert_jq "reviews count" '.reviews | length == 2' "$json"
assert_jq "review author" '.reviews[] | select(.author == "greptile-apps[bot]") | .author == "greptile-apps[bot]"' "$json"
assert_jq "issue comments count" '.issue_comments | length == 2' "$json"
assert_jq "issue comment author" '.issue_comments[] | select(.author == "github-actions") | .body | contains("Verdict")' "$json"
assert_jq "no errors" '.errors == []' "$json"
pass "snapshot happy path"
}
test_partial_failure_records_error_but_keeps_other_data() {
local tmp
make_tmp_dir tmp
make_mock_gh "$tmp/bin"
local json
GH_FAIL_REVIEWS=1 PATH="$tmp/bin:$PATH" "$SCRIPT" 123 >"$tmp/out.json"
json="$(<"$tmp/out.json")"
assert_jq "reviews empty on failure" '.reviews == []' "$json"
assert_jq "checks still present" '.checks | length == 3' "$json"
assert_jq "issue comments still present" '.issue_comments | length == 2' "$json"
assert_jq "partial failure recorded" '.errors | length == 1' "$json"
assert_jq "partial failure source" '.errors[0].source == "reviews"' "$json"
pass "partial failure records error but keeps other data"
}
test_pr_view_failure_with_non_numeric_ref_keeps_schema() {
local tmp
make_tmp_dir tmp
make_mock_gh "$tmp/bin"
local json
GH_FAIL_PR_VIEW=1 PATH="$tmp/bin:$PATH" "$SCRIPT" feat/pr-state >"$tmp/out.json"
json="$(<"$tmp/out.json")"
assert_jq "pr number is null on ref-based invocation" '.pr.number == null' "$json"
assert_jq "pr branch preserved" '.pr.branch == "feat/pr-state"' "$json"
assert_jq "pr_view error recorded" '.errors[] | select(.source == "pr_view") | .message == "gh pr view failed"' "$json"
pass "pr_view failure keeps schema stable for non-numeric refs"
}
test_repo_failure_skips_review_threads() {
local tmp
make_tmp_dir tmp
make_mock_gh "$tmp/bin"
local json
GH_FAIL_REPO=1 GH_NO_PR_URL=1 PATH="$tmp/bin:$PATH" "$SCRIPT" 123 >"$tmp/out.json"
json="$(<"$tmp/out.json")"
assert_jq "checks still present on repo failure" '.checks | length == 3' "$json"
assert_jq "review threads empty on repo failure" '.review_threads == []' "$json"
assert_jq "unresolved count unknown on repo failure" '.unresolved_review_thread_count == null' "$json"
assert_jq "repo failure recorded" '.errors[] | select(.source == "repo") | .message == "gh repo view failed"' "$json"
assert_jq "review thread skip recorded" '.errors[] | select(.source == "review_threads") | .message == "skipped: repo lookup failed"' "$json"
pass "repo failure skips review threads"
}
test_graphql_failure_records_error_but_keeps_other_data() {
local tmp
make_tmp_dir tmp
make_mock_gh "$tmp/bin"
local json
GH_FAIL_GRAPHQL=1 PATH="$tmp/bin:$PATH" "$SCRIPT" 123 >"$tmp/out.json"
json="$(<"$tmp/out.json")"
assert_jq "checks still present on graphql failure" '.checks | length == 3' "$json"
assert_jq "reviews still present on graphql failure" '.reviews | length == 2' "$json"
assert_jq "review threads empty on graphql failure" '.review_threads == []' "$json"
assert_jq "unresolved count unknown on graphql failure" '.unresolved_review_thread_count == null' "$json"
assert_jq "graphql failure recorded" '.errors[] | select(.source == "review_threads") | .message == "gh api graphql reviewThreads failed"' "$json"
pass "graphql failure records error but keeps other data"
}
test_graphql_pagination_collects_all_threads() {
local tmp
make_tmp_dir tmp
make_mock_gh "$tmp/bin"
local json
GH_GRAPHQL_TWO_PAGES=1 PATH="$tmp/bin:$PATH" "$SCRIPT" 123 >"$tmp/out.json"
json="$(<"$tmp/out.json")"
assert_jq "paginated threads count" '.review_threads | length == 2' "$json"
assert_jq "paginated first thread" '.review_threads[] | select(.thread_id == "PRRT_1") | .is_resolved == false' "$json"
assert_jq "paginated second thread" '.review_threads[] | select(.thread_id == "PRRT_2") | .is_resolved == true' "$json"
assert_jq "paginated unresolved count" '.unresolved_review_thread_count == 1' "$json"
assert_jq "paginated no errors" '.errors == []' "$json"
pass "graphql pagination collects all threads"
}
test_snapshot_happy_path
test_partial_failure_records_error_but_keeps_other_data
test_pr_view_failure_with_non_numeric_ref_keeps_schema
test_repo_failure_skips_review_threads
test_graphql_failure_records_error_but_keeps_other_data
test_graphql_pagination_collects_all_threads