chore: improve agent harness guidance (#2185)
This commit is contained in:
Executable
+179
@@ -0,0 +1,179 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Audit Playwright blob reports for retries, errors, and unexpected results."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import io
|
||||
import json
|
||||
import sys
|
||||
import zipfile
|
||||
from collections import Counter
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Iterable
|
||||
|
||||
|
||||
EXPECTED_STATUSES = {"passed", "skipped"}
|
||||
|
||||
|
||||
def is_report_jsonl(path: str | Path) -> bool:
|
||||
name = Path(path).name
|
||||
return name == "report.jsonl" or (name.startswith("report-") and name.endswith(".jsonl"))
|
||||
|
||||
|
||||
def is_report_zip(path: str | Path) -> bool:
|
||||
name = Path(path).name
|
||||
return name.startswith("report-") and name.endswith(".zip")
|
||||
|
||||
|
||||
def discover_reports(inputs: Iterable[Path]) -> list[tuple[str, Path, str | None]]:
|
||||
"""Return sorted (kind, path, member) tuples without duplicate inputs."""
|
||||
|
||||
reports: dict[tuple[str, str, str | None], tuple[str, Path, str | None]] = {}
|
||||
|
||||
for input_path in inputs:
|
||||
if not input_path.exists():
|
||||
raise FileNotFoundError(input_path)
|
||||
|
||||
candidates = [input_path] if input_path.is_file() else sorted(input_path.rglob("*"))
|
||||
for candidate in candidates:
|
||||
if not candidate.is_file():
|
||||
continue
|
||||
if candidate.suffix == ".zip" and (is_report_zip(candidate) or candidate == input_path):
|
||||
try:
|
||||
with zipfile.ZipFile(candidate) as archive:
|
||||
for member in archive.namelist():
|
||||
if is_report_jsonl(PurePosixPath(member).name):
|
||||
key = ("zip", str(candidate), member)
|
||||
reports[key] = ("zip", candidate, member)
|
||||
except zipfile.BadZipFile as exc:
|
||||
raise ValueError(f"invalid Playwright report archive: {candidate}") from exc
|
||||
elif is_report_jsonl(candidate):
|
||||
key = ("file", str(candidate), None)
|
||||
reports[key] = ("file", candidate, None)
|
||||
|
||||
return sorted(reports.values(), key=lambda report: (str(report[1]), report[2] or ""))
|
||||
|
||||
|
||||
def iter_report_lines(kind: str, path: Path, member: str | None) -> Iterable[tuple[int, str]]:
|
||||
if kind == "file":
|
||||
with path.open("r", encoding="utf-8") as stream:
|
||||
for line_number, line in enumerate(stream, start=1):
|
||||
yield line_number, line
|
||||
return
|
||||
|
||||
assert member is not None
|
||||
with zipfile.ZipFile(path) as archive:
|
||||
with archive.open(member) as binary_stream:
|
||||
stream = io.TextIOWrapper(binary_stream, encoding="utf-8")
|
||||
for line_number, line in enumerate(stream, start=1):
|
||||
yield line_number, line
|
||||
|
||||
|
||||
def result_from(event: dict) -> dict:
|
||||
params = event.get("params")
|
||||
if not isinstance(params, dict):
|
||||
params = {}
|
||||
result = params.get("result")
|
||||
if not isinstance(result, dict):
|
||||
result = event.get("result")
|
||||
return result if isinstance(result, dict) else {}
|
||||
|
||||
|
||||
def audit_reports(reports: list[tuple[str, Path, str | None]]) -> dict:
|
||||
attempt_count = 0
|
||||
retry_attempt_count = 0
|
||||
status_counts: Counter[str] = Counter()
|
||||
unexpected_statuses: Counter[str] = Counter()
|
||||
error_result_count = 0
|
||||
parse_error_count = 0
|
||||
event_count = 0
|
||||
report_names: list[str] = []
|
||||
|
||||
for kind, path, member in reports:
|
||||
report_name = f"{path}!{member}" if member is not None else str(path)
|
||||
report_names.append(report_name)
|
||||
for line_number, raw_line in iter_report_lines(kind, path, member):
|
||||
line = raw_line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
event = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
parse_error_count += 1
|
||||
continue
|
||||
if not isinstance(event, dict):
|
||||
continue
|
||||
|
||||
method = event.get("method") or event.get("event") or event.get("type")
|
||||
if method not in {"onTestBegin", "onTestEnd"}:
|
||||
continue
|
||||
event_count += 1
|
||||
result = result_from(event)
|
||||
|
||||
if method == "onTestBegin":
|
||||
attempt_count += 1
|
||||
retry = result.get("retry", 0)
|
||||
if isinstance(retry, (int, float)) and not isinstance(retry, bool) and retry > 0:
|
||||
retry_attempt_count += 1
|
||||
continue
|
||||
|
||||
status = result.get("status")
|
||||
if status is None:
|
||||
params = event.get("params")
|
||||
status = params.get("status") if isinstance(params, dict) else event.get("status")
|
||||
status_name = str(status) if status is not None else "unknown"
|
||||
status_counts[status_name] += 1
|
||||
if status_name not in EXPECTED_STATUSES:
|
||||
unexpected_statuses[status_name] += 1
|
||||
errors = result.get("errors")
|
||||
if errors:
|
||||
error_result_count += 1
|
||||
|
||||
violations: list[str] = []
|
||||
if retry_attempt_count:
|
||||
violations.append(f"retry_attempts={retry_attempt_count}")
|
||||
if error_result_count:
|
||||
violations.append(f"error_results={error_result_count}")
|
||||
if unexpected_statuses:
|
||||
violations.append(f"unexpected_statuses={dict(sorted(unexpected_statuses.items()))}")
|
||||
if parse_error_count:
|
||||
violations.append(f"parse_errors={parse_error_count}")
|
||||
if not event_count:
|
||||
violations.append("no_test_events")
|
||||
|
||||
return {
|
||||
"reports": report_names,
|
||||
"report_count": len(report_names),
|
||||
"attempt_count": attempt_count,
|
||||
"retry_attempt_count": retry_attempt_count,
|
||||
"status_counts": dict(sorted(status_counts.items())),
|
||||
"error_result_count": error_result_count,
|
||||
"unexpected_statuses": dict(sorted(unexpected_statuses.items())),
|
||||
"parse_error_count": parse_error_count,
|
||||
"violations": violations,
|
||||
}
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Audit Playwright report-*.zip/report.jsonl artifacts for retries and errors."
|
||||
)
|
||||
parser.add_argument("paths", nargs="+", type=Path, help="report files, archives, or directories")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
try:
|
||||
reports = discover_reports(args.paths)
|
||||
if not reports:
|
||||
raise ValueError("no report-*.zip or report.jsonl artifacts found")
|
||||
summary = audit_reports(reports)
|
||||
except (FileNotFoundError, OSError, ValueError) as exc:
|
||||
print(f"playwright-blob-audit: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
print(json.dumps(summary, indent=2, sort_keys=True))
|
||||
return 1 if summary["violations"] else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Executable
+85
@@ -0,0 +1,85 @@
|
||||
#!/usr/bin/env python3
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SCRIPT = ROOT / "scripts" / "playwright-blob-audit"
|
||||
|
||||
|
||||
def event(method: str, result: dict) -> str:
|
||||
return json.dumps({"method": method, "params": {"result": result}})
|
||||
|
||||
|
||||
class PlaywrightBlobAuditTest(unittest.TestCase):
|
||||
def run_audit(self, path: Path) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
[sys.executable, str(SCRIPT), str(path)],
|
||||
cwd=ROOT,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
def test_passes_for_recursive_zip_report(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
archive_path = root / "nested" / "report-001.zip"
|
||||
archive_path.parent.mkdir()
|
||||
with zipfile.ZipFile(archive_path, "w") as archive:
|
||||
archive.writestr(
|
||||
"report.jsonl",
|
||||
"\n".join(
|
||||
[
|
||||
event("onTestBegin", {"retry": 0}),
|
||||
event("onTestEnd", {"status": "passed", "errors": []}),
|
||||
event("onTestBegin", {"retry": 0}),
|
||||
event("onTestEnd", {"status": "skipped", "errors": []}),
|
||||
]
|
||||
)
|
||||
+ "\n",
|
||||
)
|
||||
|
||||
result = self.run_audit(root)
|
||||
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
summary = json.loads(result.stdout)
|
||||
self.assertEqual(summary["report_count"], 1)
|
||||
self.assertEqual(summary["attempt_count"], 2)
|
||||
self.assertEqual(summary["retry_attempt_count"], 0)
|
||||
self.assertEqual(summary["status_counts"], {"passed": 1, "skipped": 1})
|
||||
|
||||
def test_fails_for_retry_error_and_unexpected_status(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
report_path = Path(directory) / "report.jsonl"
|
||||
report_path.write_text(
|
||||
"\n".join(
|
||||
[
|
||||
event("onTestBegin", {"retry": 1}),
|
||||
event("onTestEnd", {"status": "failed", "errors": [{"message": "boom"}]}),
|
||||
]
|
||||
)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = self.run_audit(report_path)
|
||||
|
||||
self.assertEqual(result.returncode, 1, result.stderr)
|
||||
summary = json.loads(result.stdout)
|
||||
self.assertEqual(summary["retry_attempt_count"], 1)
|
||||
self.assertEqual(summary["error_result_count"], 1)
|
||||
self.assertEqual(summary["unexpected_statuses"], {"failed": 1})
|
||||
self.assertEqual(
|
||||
summary["violations"],
|
||||
["retry_attempts=1", "error_results=1", "unexpected_statuses={'failed': 1}"],
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+133
-3
@@ -8,7 +8,8 @@
|
||||
#
|
||||
# Raw output:
|
||||
# JSON object with:
|
||||
# - pr: { number, branch, is_cross_repository, head_repository_owner,
|
||||
# - pr: { number, branch, is_cross_repository, base_ref_name, base_head_oid,
|
||||
# merge_base_oid, base_advanced_since_head, head_repository_owner,
|
||||
# head_repository_name, head_ref_name, head_ref_oid, maintainer_can_modify }
|
||||
# - since: { head_oid, committed_at } or null
|
||||
# - checks: [{ type, name, workflow, status, conclusion, details_url, target_url, run_id, job_id }]
|
||||
@@ -30,7 +31,8 @@
|
||||
#
|
||||
# Summary output (--summary):
|
||||
# JSON object with:
|
||||
# - pr: { number, branch, is_cross_repository, head_repository_owner,
|
||||
# - pr: { number, branch, is_cross_repository, base_ref_name, base_head_oid,
|
||||
# merge_base_oid, base_advanced_since_head, head_repository_owner,
|
||||
# head_repository_name, head_ref_name, head_ref_oid, maintainer_can_modify }
|
||||
# - since: { head_oid, committed_at } or null
|
||||
# - failed_checks: [{ name, workflow, status, conclusion, run_id, job_id, details_url, target_url }]
|
||||
@@ -148,9 +150,14 @@ fi
|
||||
|
||||
errors='[]'
|
||||
pr_json='{}'
|
||||
pr_view_succeeded=false
|
||||
checks_json='[]'
|
||||
checks_head_sha=''
|
||||
checks_snapshot_complete=false
|
||||
base_ref_name=''
|
||||
base_head_oid=''
|
||||
merge_base_oid=''
|
||||
base_advanced_since_head=null
|
||||
approval_required_runs_json='[]'
|
||||
review_threads_json='[]'
|
||||
hidden_unresolved_threads_json='[]'
|
||||
@@ -670,6 +677,38 @@ fetch_head_commit_json() {
|
||||
}'
|
||||
}
|
||||
|
||||
fetch_base_ref_json() {
|
||||
local owner=$1
|
||||
local repo=$2
|
||||
local pr_number=$3
|
||||
|
||||
gh_json api graphql \
|
||||
-f query='
|
||||
query($owner: String!, $repo: String!, $pr: Int!) {
|
||||
repository(owner: $owner, name: $repo) {
|
||||
pullRequest(number: $pr) {
|
||||
baseRefName
|
||||
baseRefOid
|
||||
}
|
||||
}
|
||||
}' \
|
||||
-f owner="$owner" -f repo="$repo" -F pr="$pr_number" |
|
||||
jq -c '{
|
||||
base_ref_name: (.data.repository.pullRequest.baseRefName // null),
|
||||
base_head_oid: (.data.repository.pullRequest.baseRefOid // null)
|
||||
}'
|
||||
}
|
||||
|
||||
fetch_merge_base_json() {
|
||||
local owner=$1
|
||||
local repo=$2
|
||||
local base_head_oid=$3
|
||||
local head_oid=$4
|
||||
|
||||
gh_json api "repos/$owner/$repo/compare/$base_head_oid...$head_oid" |
|
||||
jq -c '{merge_base_oid: (.merge_base_commit.sha // null)}'
|
||||
}
|
||||
|
||||
fetch_review_threads_nodes_json() {
|
||||
local owner=$1
|
||||
local repo=$2
|
||||
@@ -761,7 +800,8 @@ comments_json='[]'
|
||||
status_json='[]'
|
||||
resolved_pr_number=''
|
||||
is_cross_repository=null
|
||||
if pr_view_json="$(gh_json pr view "$PR" --json number,headRefName,headRefOid,headRepositoryOwner,headRepository,maintainerCanModify,isCrossRepository,statusCheckRollup,comments,url)"; then
|
||||
if pr_view_json="$(gh_json pr view "$PR" --json number,baseRefName,headRefName,headRefOid,headRepositoryOwner,headRepository,maintainerCanModify,isCrossRepository,statusCheckRollup,comments,url)"; then
|
||||
pr_view_succeeded=true
|
||||
resolved_pr_number="$(jq -r '.number // ""' <<<"$pr_view_json")"
|
||||
if pr_url_owner="$(jq -r '.url | capture("github.com/(?<owner>[^/]+)/(?<repo>[^/]+)/pull/[0-9]+").owner // ""' <<<"$pr_view_json" 2>/dev/null)" && [[ -n "$pr_url_owner" ]]; then
|
||||
repo_owner="$pr_url_owner"
|
||||
@@ -771,6 +811,10 @@ if pr_view_json="$(gh_json pr view "$PR" --json number,headRefName,headRefOid,he
|
||||
number: .number,
|
||||
branch: (.headRefName // "unknown"),
|
||||
is_cross_repository: (if has("isCrossRepository") then .isCrossRepository else null end),
|
||||
base_ref_name: (.baseRefName // null),
|
||||
base_head_oid: null,
|
||||
merge_base_oid: null,
|
||||
base_advanced_since_head: null,
|
||||
head_repository_owner: (.headRepositoryOwner.login // null),
|
||||
head_repository_name: (.headRepository.name // null),
|
||||
head_ref_name: (.headRefName // null),
|
||||
@@ -791,6 +835,43 @@ else
|
||||
add_error "pr_view" "gh pr view failed"
|
||||
fi
|
||||
|
||||
if [[ "$pr_view_succeeded" == "true" && -n "$repo_owner" && -n "$repo_name" && -n "$resolved_pr_number" ]]; then
|
||||
if base_context_json="$(fetch_base_ref_json "$repo_owner" "$repo_name" "$resolved_pr_number")"; then
|
||||
base_ref_name="$(jq -r '.base_ref_name // ""' <<<"$base_context_json")"
|
||||
base_head_oid="$(jq -r '.base_head_oid // ""' <<<"$base_context_json")"
|
||||
else
|
||||
add_error "base" "gh api graphql base ref lookup failed"
|
||||
fi
|
||||
|
||||
if [[ -n "$base_head_oid" && -n "$checks_head_sha" ]]; then
|
||||
if merge_base_json="$(fetch_merge_base_json "$repo_owner" "$repo_name" "$base_head_oid" "$checks_head_sha")"; then
|
||||
merge_base_oid="$(jq -r '.merge_base_oid // ""' <<<"$merge_base_json")"
|
||||
else
|
||||
add_error "merge_base" "gh api compare base and head failed"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -n "$base_head_oid" && -n "$merge_base_oid" ]]; then
|
||||
if [[ "$base_head_oid" == "$merge_base_oid" ]]; then
|
||||
base_advanced_since_head=false
|
||||
else
|
||||
base_advanced_since_head=true
|
||||
fi
|
||||
fi
|
||||
|
||||
pr_json="$(jq -c \
|
||||
--arg base_ref_name "$base_ref_name" \
|
||||
--arg base_head_oid "$base_head_oid" \
|
||||
--arg merge_base_oid "$merge_base_oid" \
|
||||
--argjson base_advanced_since_head "$base_advanced_since_head" \
|
||||
'. + {
|
||||
base_ref_name: (if $base_ref_name == "" then null else $base_ref_name end),
|
||||
base_head_oid: (if $base_head_oid == "" then null else $base_head_oid end),
|
||||
merge_base_oid: (if $merge_base_oid == "" then null else $merge_base_oid end),
|
||||
base_advanced_since_head: $base_advanced_since_head
|
||||
}' <<<"$pr_json")"
|
||||
fi
|
||||
|
||||
if ! approval_required_runs_json="$(fetch_approval_required_runs_json "$repo_owner" "$repo_name" "$resolved_pr_number" "$checks_head_sha")"; then
|
||||
approval_required_runs_json='[]'
|
||||
add_error "approval_required_runs" "workflow runs API failed"
|
||||
@@ -865,6 +946,55 @@ if [[ -n "$repo_owner" && -n "$repo_name" && -n "$resolved_pr_number" ]]; then
|
||||
add_error "closing_head" "gh api graphql closing head commit failed"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Re-read the base alongside the closing head. The base can advance while
|
||||
# reviews and checks are collected, so the initial divergence fields are not a
|
||||
# safe final snapshot.
|
||||
if [[ -n "$repo_owner" && -n "$repo_name" && -n "$resolved_pr_number" ]]; then
|
||||
if closing_base_context_json="$(fetch_base_ref_json "$repo_owner" "$repo_name" "$resolved_pr_number")"; then
|
||||
closing_base_ref_name="$(jq -r '.base_ref_name // ""' <<<"$closing_base_context_json")"
|
||||
closing_base_head_oid="$(jq -r '.base_head_oid // ""' <<<"$closing_base_context_json")"
|
||||
closing_merge_base_oid=''
|
||||
comparison_head_oid="$closing_head_oid"
|
||||
[[ -n "$comparison_head_oid" ]] || comparison_head_oid="$checks_head_sha"
|
||||
if [[ -n "$closing_base_head_oid" && -n "$comparison_head_oid" ]]; then
|
||||
if closing_merge_base_json="$(fetch_merge_base_json "$repo_owner" "$repo_name" "$closing_base_head_oid" "$comparison_head_oid")"; then
|
||||
closing_merge_base_oid="$(jq -r '.merge_base_oid // ""' <<<"$closing_merge_base_json")"
|
||||
else
|
||||
add_error "closing_merge_base" "gh api compare base and head failed after evidence collection"
|
||||
fi
|
||||
fi
|
||||
base_ref_name="$closing_base_ref_name"
|
||||
base_head_oid="$closing_base_head_oid"
|
||||
merge_base_oid="$closing_merge_base_oid"
|
||||
base_advanced_since_head=null
|
||||
if [[ -n "$base_head_oid" && -n "$merge_base_oid" ]]; then
|
||||
if [[ "$base_head_oid" == "$merge_base_oid" ]]; then
|
||||
base_advanced_since_head=false
|
||||
else
|
||||
base_advanced_since_head=true
|
||||
fi
|
||||
fi
|
||||
else
|
||||
add_error "closing_base" "gh api graphql base ref lookup failed after evidence collection"
|
||||
base_ref_name=''
|
||||
base_head_oid=''
|
||||
merge_base_oid=''
|
||||
base_advanced_since_head=null
|
||||
fi
|
||||
pr_json="$(jq -c \
|
||||
--arg base_ref_name "$base_ref_name" \
|
||||
--arg base_head_oid "$base_head_oid" \
|
||||
--arg merge_base_oid "$merge_base_oid" \
|
||||
--argjson base_advanced_since_head "$base_advanced_since_head" \
|
||||
'. + {
|
||||
base_ref_name: (if $base_ref_name == "" then null else $base_ref_name end),
|
||||
base_head_oid: (if $base_head_oid == "" then null else $base_head_oid end),
|
||||
merge_base_oid: (if $merge_base_oid == "" then null else $merge_base_oid end),
|
||||
base_advanced_since_head: $base_advanced_since_head
|
||||
}' <<<"$pr_json")"
|
||||
fi
|
||||
|
||||
if [[ -n "$opening_head_oid" && -n "$closing_head_oid" && "$opening_head_oid" != "$closing_head_oid" ]]; then
|
||||
add_error "review_evidence" "head changed during evidence collection"
|
||||
fi
|
||||
|
||||
@@ -79,6 +79,20 @@ if [[ "$1" == "repo" && "$2" == "view" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$1" == "api" && "$2" == repos/kdlbs/kandev/compare/* ]]; then
|
||||
merge_base="${GH_MERGE_BASE:-base-branch-sha}"
|
||||
if [[ "${GH_BASE_SEQUENCE:-stable}" == "race" && "$2" == *"late-base-head-sha..."* ]]; then
|
||||
merge_base="late-merge-base-sha"
|
||||
fi
|
||||
cat <<JSON
|
||||
{
|
||||
"base_commit": { "sha": "${GH_BASE_HEAD:-base-head-sha}" },
|
||||
"merge_base_commit": { "sha": "$merge_base" }
|
||||
}
|
||||
JSON
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$1" == "api" && "$2" == "repos/kdlbs/kandev/pulls/comments/111" ]]; then
|
||||
cat <<'JSON'
|
||||
{
|
||||
@@ -170,6 +184,7 @@ if [[ "$1" == "pr" && "$2" == "view" && "$4" == "--json" ]]; then
|
||||
cat <<JSON
|
||||
{
|
||||
"number": 123,
|
||||
"baseRefName": "${GH_BASE_REF_NAME:-main}",
|
||||
"headRefName": "feat/pr-state",
|
||||
"headRefOid": "${GH_CHECKS_HEAD:-abc123}",
|
||||
"headRepositoryOwner": { "login": "${GH_HEAD_REPOSITORY_OWNER:-kdlbs}" },
|
||||
@@ -524,7 +539,29 @@ if [[ "$1" == "api" && "$2" == "--paginate" && "$3" == "-X" && "$4" == "GET" &&
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$1" == "api" && "$2" == "graphql" ]]; then
|
||||
if [[ "$1" == "api" && "$2" == "graphql" ]]; then
|
||||
if [[ "$*" == *"baseRefOid"* && "$*" != *"headRefOid"* ]]; then
|
||||
base_head="${GH_BASE_HEAD:-base-head-sha}"
|
||||
if [[ "${GH_BASE_SEQUENCE:-stable}" == "race" ]]; then
|
||||
if [[ -f "${GH_BASE_COUNTER_FILE:?}" ]]; then
|
||||
base_head="late-base-head-sha"
|
||||
else
|
||||
: >"$GH_BASE_COUNTER_FILE"
|
||||
fi
|
||||
fi
|
||||
printf '%s\n' '{
|
||||
"data": {
|
||||
"repository": {
|
||||
"pullRequest": {
|
||||
"baseRefName": "main",
|
||||
"baseRefOid": "'"$base_head"'"
|
||||
}
|
||||
}
|
||||
}
|
||||
}'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$*" == *"headRefOid"* ]]; then
|
||||
head_oid="abc123"
|
||||
if [[ "${GH_HEAD_SEQUENCE:-stable}" == "race" ]]; then
|
||||
@@ -701,6 +738,7 @@ test_snapshot_happy_path() {
|
||||
assert_jq "pr number" '.pr.number == 123' "$json"
|
||||
assert_jq "branch" '.pr.branch == "feat/pr-state"' "$json"
|
||||
assert_jq "head delivery target" '.pr.head_repository_owner == "kdlbs" and .pr.head_repository_name == "kandev" and .pr.head_ref_name == "feat/pr-state" and .pr.head_ref_oid == "abc123" and .pr.maintainer_can_modify == true' "$json"
|
||||
assert_jq "base divergence fields" '.pr.base_ref_name == "main" and .pr.base_head_oid == "base-head-sha" and .pr.merge_base_oid == "base-branch-sha" and .pr.base_advanced_since_head == true' "$json"
|
||||
assert_jq "since timestamp" '.since.committed_at == "2026-06-01T12:00:00Z"' "$json"
|
||||
assert_jq "checks collapse duplicate workflow attempts" '.checks | length < 10' "$json"
|
||||
assert_jq "latest duplicate check uses newest attempt" '[.checks[] | select(.name == "web lint")][0] | .conclusion == "success" and .run_id == "27340000001"' "$json"
|
||||
@@ -1142,10 +1180,12 @@ test_graphql_failure_records_error_but_keeps_other_data() {
|
||||
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 records opening and closing head errors" '.errors | length == 3' "$json"
|
||||
assert_jq "graphql failure records base, opening, and closing errors" '.errors | length == 5' "$json"
|
||||
assert_jq "graphql failure records base lookup error" '.errors[] | select(.source == "base") | .message == "gh api graphql base ref lookup failed"' "$json"
|
||||
assert_jq "graphql failure records since error" '.errors[] | select(.source == "since") | .message == "gh api graphql head commit failed; including historical comments"' "$json"
|
||||
assert_jq "graphql failure records review_threads error" '.errors[] | select(.source == "review_threads") | .message == "gh api graphql reviewThreads failed"' "$json"
|
||||
assert_jq "graphql failure records closing head error" '.errors[] | select(.source == "closing_head") | .message == "gh api graphql closing head commit failed"' "$json"
|
||||
assert_jq "graphql failure records closing base error" '.errors[] | select(.source == "closing_base") | .message == "gh api graphql base ref lookup failed after evidence collection"' "$json"
|
||||
assert_jq "since fallback includes all historical comments" '.issue_comments | length == 8' "$json"
|
||||
pass "graphql failure records error but keeps other data"
|
||||
}
|
||||
@@ -1196,6 +1236,7 @@ test_summary_mode_returns_compact_fixup_state() {
|
||||
|
||||
assert_jq "summary keeps pr" '.pr.number == 123' "$json"
|
||||
assert_jq "summary keeps authoritative head delivery target" '.pr.head_repository_owner == "kdlbs" and .pr.head_repository_name == "kandev" and .pr.head_ref_name == "feat/pr-state" and .pr.head_ref_oid == "abc123" and .pr.maintainer_can_modify == true' "$json"
|
||||
assert_jq "summary keeps base divergence fields" '.pr.base_ref_name == "main" and .pr.base_head_oid == "base-head-sha" and .pr.merge_base_oid == "base-branch-sha" and .pr.base_advanced_since_head == true' "$json"
|
||||
assert_jq "summary keeps since" '.since.committed_at == "2026-06-01T12:00:00Z"' "$json"
|
||||
assert_jq "summary has no approval-required runs by default" '.approval_required_runs == []' "$json"
|
||||
assert_jq "summary failed check count" '.failed_checks | length == 3' "$json"
|
||||
@@ -1234,6 +1275,32 @@ test_summary_reports_current_head_fork_approval_runs() {
|
||||
pass "--summary reports current-head fork approval runs"
|
||||
}
|
||||
|
||||
test_summary_reports_base_not_advanced_when_head_matches_merge_base() {
|
||||
local tmp
|
||||
make_tmp_dir tmp
|
||||
make_mock_gh "$tmp/bin"
|
||||
|
||||
local json
|
||||
GH_BASE_HEAD=base-branch-sha GH_MERGE_BASE=base-branch-sha PATH="$tmp/bin:$PATH" "$SCRIPT" --summary 123 >"$tmp/out.json"
|
||||
json="$(<"$tmp/out.json")"
|
||||
|
||||
assert_jq "summary reports base not advanced" '.pr.base_advanced_since_head == false' "$json"
|
||||
pass "summary reports base not advanced when head matches merge base"
|
||||
}
|
||||
|
||||
test_summary_revalidates_base_at_closing_head() {
|
||||
local tmp
|
||||
make_tmp_dir tmp
|
||||
make_mock_gh "$tmp/bin"
|
||||
|
||||
local json
|
||||
GH_BASE_SEQUENCE=race GH_BASE_COUNTER_FILE="$tmp/base-calls" GH_MERGE_BASE=base-head-sha PATH="$tmp/bin:$PATH" "$SCRIPT" --summary 123 >"$tmp/out.json"
|
||||
json="$(<"$tmp/out.json")"
|
||||
|
||||
assert_jq "summary uses closing base ref" '.pr.base_head_oid == "late-base-head-sha" and .pr.merge_base_oid == "late-merge-base-sha" and .pr.base_advanced_since_head == true' "$json"
|
||||
pass "summary revalidates base at closing head"
|
||||
}
|
||||
|
||||
test_summary_reports_approval_run_fetch_failure() {
|
||||
local tmp
|
||||
make_tmp_dir tmp
|
||||
@@ -1448,6 +1515,8 @@ test_graphql_pagination_collects_all_threads
|
||||
test_all_flag_includes_historical_comments_and_reviews
|
||||
test_summary_mode_returns_compact_fixup_state
|
||||
test_summary_reports_current_head_fork_approval_runs
|
||||
test_summary_reports_base_not_advanced_when_head_matches_merge_base
|
||||
test_summary_revalidates_base_at_closing_head
|
||||
test_summary_reports_approval_run_fetch_failure
|
||||
test_summary_all_flag_includes_historical_unresolved_threads
|
||||
test_job_log_mode_emits_bounded_failure_context
|
||||
|
||||
Reference in New Issue
Block a user