diff --git a/.github/workflows/upstream-sync.yml b/.github/workflows/upstream-sync.yml new file mode 100644 index 0000000000..6d18bfc070 --- /dev/null +++ b/.github/workflows/upstream-sync.yml @@ -0,0 +1,418 @@ +name: Upstream Sync Monitor + +on: + schedule: + # Weekdays at 9am UTC + - cron: '0 9 * * 1-5' + workflow_dispatch: + inputs: + dry_run: + description: 'Skip push and PR creation (validate pipeline only)' + type: boolean + default: false + +jobs: + sync: + name: Sync upstream rrweb commits + runs-on: ubuntu-22.04 + permissions: + contents: write + pull-requests: write + # Required by anthropics/claude-code-action for OIDC-based GitHub token setup + id-token: write + + steps: + - name: Checkout amplitude/rrweb + uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Configure git + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + - name: Add upstream remote + run: | + git remote add upstream https://github.com/rrweb-io/rrweb.git + git fetch upstream master --no-tags + + - name: Determine last sync point + id: last_sync + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + FALLBACK="3e9e42fdfd6349087d7a0345af1b39dd56528502" + + # Look for the most recent upstream-sync PR (open first, then merged) + # PR titles look like: chore: upstream rrweb sync 2026-04-16 (4 commits) [last: abc1234def] + LAST_HASH="" + for STATE in open closed; do + TITLE=$(gh pr list \ + --label upstream-sync \ + --state "$STATE" \ + --limit 1 \ + --sort updated \ + --json title \ + --jq '.[0].title // empty' 2>/dev/null || true) + + if [ -n "$TITLE" ]; then + LAST_HASH=$(echo "$TITLE" | sed -n 's/.*\[last: \([0-9a-f]\{1,\}\)\].*/\1/p') + if [ -n "$LAST_HASH" ]; then + echo "Found last sync hash from $STATE PR: $LAST_HASH" + break + fi + fi + done + + if [ -z "$LAST_HASH" ]; then + LAST_HASH="$FALLBACK" + echo "No previous sync PR found; using fallback: $LAST_HASH" + fi + + echo "hash=$LAST_HASH" >> "$GITHUB_OUTPUT" + + - name: Collect new upstream commits + id: collect + run: | + LAST_SYNC="${{ steps.last_sync.outputs.hash }}" + + # Commits in upstream/master after the last sync point. + # Do not hand-build JSON from %s/%an: quotes and backslashes in subjects break jq. + # One line per commit: fields separated by US (\x1f); git's trailing newline separates records. + COMMITS=$(git log ${LAST_SYNC}..upstream/master --format='%H%x1f%h%x1f%s%x1f%an%x1f%ci' | jq -Rs ' + split("\n") + | map(select(length > 0)) + | map(split("\u001f")) + | map({ + hash: .[0], + short: .[1], + subject: .[2], + author: .[3], + date: .[4] + }) + ') + + COUNT=$(echo "$COMMITS" | jq 'length') + + # The newest commit in the range — this becomes the next sync point. + # git log outputs newest-first, so .[0] is the tip of upstream/master. + LAST_UPSTREAM_HASH=$(echo "$COMMITS" | jq -r '.[0].hash // empty') + + echo "count=$COUNT" >> "$GITHUB_OUTPUT" + echo "commits<> "$GITHUB_OUTPUT" + echo "$COMMITS" >> "$GITHUB_OUTPUT" + echo "EOF" >> "$GITHUB_OUTPUT" + echo "last_upstream_hash=$LAST_UPSTREAM_HASH" >> "$GITHUB_OUTPUT" + + echo "Found $COUNT new upstream commits since $LAST_SYNC" + echo "Last upstream commit: $LAST_UPSTREAM_HASH" + + - name: Exit early if no new commits + if: steps.collect.outputs.count == '0' + run: | + echo "No new upstream commits. Nothing to do." + exit 0 + + - name: Create sync branch + id: branch + run: | + BRANCH="upstream-sync/$(date +%Y-%m-%d)" + git checkout -b "$BRANCH" + echo "branch=$BRANCH" >> "$GITHUB_OUTPUT" + + - name: Analyze commits and cherry-pick with Claude + id: claude + uses: anthropics/claude-code-action@c7c8889b30499b4e46f4c32b892e43cd364bc2fe + with: + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + claude_args: '--max-turns 3 --allowedTools Bash,Read,Edit' + prompt: | + You are helping maintain `amplitude/rrweb`, a fork of `rrweb-io/rrweb`. + Amplitude extends rrweb with custom functionality for Session Replay (background + capture, privacy masking, performance optimizations). + + Your task is to triage the following upstream commits, cherry-pick the relevant + ones onto the current branch, and resolve any conflicts. + + ## Upstream commits to evaluate + ```json + ${{ steps.collect.outputs.commits }} + ``` + + ## Instructions + + For each commit, decide if it is RELEVANT or SKIP: + + **Relevant** — cherry-pick it: + - Bug fixes in rrweb-snapshot, rrweb, or record packages + - Performance improvements + - Privacy or masking changes + - Browser compatibility fixes + + **Skip** — do not cherry-pick: + - CI/CD, tooling, or release process changes + - Docs or example-only changes + - Features Amplitude has already implemented differently + - Changes to packages Amplitude doesn't use + + Also assign a risk level to each relevant commit: + - `low` — isolated fix, minimal blast radius + - `medium` — touches shared logic, needs testing + - `high` — core serialization, replay, or snapshot changes + + ## Cherry-pick process + + For each RELEVANT commit (oldest first): + 1. Run: `git cherry-pick ` + 2. If it succeeds, move on. + 3. If it fails with conflicts: + a. Use the Read tool to read each conflicting file (they will have conflict markers). + b. Use the Edit tool to resolve the conflicts, preserving Amplitude's custom logic + while incorporating the upstream fix. + c. Run: `git add ` for each resolved file. + d. Run: `git cherry-pick --continue --no-edit` + e. If the conflict is too complex to resolve safely, run: + `git cherry-pick --abort` + and mark the commit as `manual-required`. + + ## Output + + After processing all commits, write a JSON summary to `/tmp/sync-summary.json`: + ```json + { + "cherry_picked": [ + { + "hash": "abc1234", + "subject": "fix: textarea serialization", + "risk": "low", + "summary": "One-line description of what the fix does", + "conflict_resolved": false, + "conflict_resolution_note": null + } + ], + "skipped": [ + { + "hash": "def5678", + "subject": "chore: update CI config", + "reason": "CI-only change, not relevant to Amplitude fork" + } + ], + "manual_required": [ + { + "hash": "ghi9012", + "subject": "refactor: reorganize snapshot exports", + "reason": "Structural reorganization conflicts with Amplitude's export overrides" + } + ] + } + ``` + + For any commit where you resolved a conflict, set `conflict_resolved: true` and + populate `conflict_resolution_note` with a brief explanation of what you decided + and why. + + If a conflict was resolved but you are uncertain about the decision, prefix the + note with `[UNCERTAIN] `. + + - name: Read Claude summary + id: summary + run: | + if [ ! -f /tmp/sync-summary.json ]; then + echo "ERROR: Claude did not produce /tmp/sync-summary.json" + exit 1 + fi + + cat /tmp/sync-summary.json + + PICKED=$(jq '.cherry_picked | length' /tmp/sync-summary.json) + SKIPPED=$(jq '.skipped | length' /tmp/sync-summary.json) + MANUAL=$(jq '.manual_required | length' /tmp/sync-summary.json) + HIGH_RISK=$(jq '[.cherry_picked[] | select(.risk == "high")] | length' /tmp/sync-summary.json) + + echo "picked=$PICKED" >> "$GITHUB_OUTPUT" + echo "skipped=$SKIPPED" >> "$GITHUB_OUTPUT" + echo "manual=$MANUAL" >> "$GITHUB_OUTPUT" + echo "high_risk=$HIGH_RISK" >> "$GITHUB_OUTPUT" + + - name: Exit early if nothing was cherry-picked + if: steps.summary.outputs.picked == '0' + run: | + echo "No relevant commits to cherry-pick. Exiting." + exit 0 + + - name: Build PR body + id: pr_body + run: | + DATE=$(date +%Y-%m-%d) + PICKED=${{ steps.summary.outputs.picked }} + SUMMARY_JSON=$(cat /tmp/sync-summary.json) + + # Build commit table rows + ROWS=$(echo "$SUMMARY_JSON" | jq -r ' + .cherry_picked[] | + "| `\(.hash[0:7])` | \(.subject) | `\(.risk)` | \(if .conflict_resolved then "⚠️ " else "" end)\(.summary) |" + ') + + SKIPPED_ROWS=$(echo "$SUMMARY_JSON" | jq -r ' + .skipped[] | + "| `\(.hash[0:7])` | \(.subject) | \(.reason) |" + ') + + MANUAL_ROWS=$(echo "$SUMMARY_JSON" | jq -r ' + .manual_required[] | + "| `\(.hash[0:7])` | \(.subject) | \(.reason) |" + ') + + CONFLICT_NOTES=$(echo "$SUMMARY_JSON" | jq -r ' + .cherry_picked[] | select(.conflict_resolved == true) | + "- **`\(.hash[0:7])`** \(.subject)\n \(.conflict_resolution_note)" + ') + + LAST_SYNC="${{ steps.last_sync.outputs.hash }}" + LAST_UPSTREAM="${{ steps.collect.outputs.last_upstream_hash }}" + + { + echo "body<" + echo "# Resolve conflicts in your editor" + echo "git add ." + echo "git cherry-pick --continue" + echo "\`\`\`" + echo "" + fi + + SKIPPED_COUNT=$(echo "$SUMMARY_JSON" | jq '.skipped | length') + if [ "$SKIPPED_COUNT" -gt 0 ]; then + echo "
" + echo "Skipped commits ($SKIPPED_COUNT)" + echo "" + echo "| Commit | Subject | Reason |" + echo "|--------|---------|--------|" + echo "$SKIPPED_ROWS" + echo "" + echo "
" + fi + + echo "" + echo "---" + echo "_Generated by [Upstream Sync Monitor](/.github/workflows/upstream-sync.yml)_" + echo "PRBODY" + } >> "$GITHUB_OUTPUT" + + - name: Push branch + if: inputs.dry_run != true + run: git push origin ${{ steps.branch.outputs.branch }} + + - name: Create draft PR + id: pr + if: inputs.dry_run != true + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + PICKED=${{ steps.summary.outputs.picked }} + DATE=$(date +%Y-%m-%d) + LAST_HASH="${{ steps.collect.outputs.last_upstream_hash }}" + TITLE="chore: upstream rrweb sync $DATE ($PICKED commit(s)) [last: $LAST_HASH]" + + PR_URL=$(gh pr create \ + --title "$TITLE" \ + --body "${{ steps.pr_body.outputs.body }}" \ + --base master \ + --head ${{ steps.branch.outputs.branch }} \ + --draft \ + --label upstream-sync) + + echo "pr_url=$PR_URL" >> "$GITHUB_OUTPUT" + echo "Created PR: $PR_URL" + + - name: Post Slack digest + if: inputs.dry_run != true + env: + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} + run: | + PICKED=${{ steps.summary.outputs.picked }} + SKIPPED=${{ steps.summary.outputs.skipped }} + MANUAL=${{ steps.summary.outputs.manual }} + HIGH_RISK=${{ steps.summary.outputs.high_risk }} + PR_URL="${{ steps.pr.outputs.pr_url }}" + DATE=$(date +%Y-%m-%d) + + HIGH_RISK_NOTE="" + if [ "$HIGH_RISK" -gt 0 ]; then + HIGH_RISK_NOTE="\n:warning: *$HIGH_RISK high-risk commit(s) included — review carefully.*" + fi + + MANUAL_NOTE="" + if [ "$MANUAL" -gt 0 ]; then + MANUAL_NOTE="\n:red_circle: *$MANUAL commit(s) need manual cherry-pick.*" + fi + + PAYLOAD=$(jq -n \ + --arg date "$DATE" \ + --arg picked "$PICKED" \ + --arg skipped "$SKIPPED" \ + --arg high_risk_note "$HIGH_RISK_NOTE" \ + --arg manual_note "$MANUAL_NOTE" \ + --arg pr_url "$PR_URL" \ + '{ + text: ("*Upstream rrweb sync — " + $date + "*\n" + + "• " + $picked + " commit(s) cherry-picked\n" + + "• " + $skipped + " commit(s) skipped\n" + + $high_risk_note + + $manual_note + "\n" + + "<" + $pr_url + "|View draft PR>") + }') + + curl -s -X POST -H 'Content-type: application/json' \ + --data "$PAYLOAD" \ + "$SLACK_WEBHOOK_URL" + + - name: Dry run summary + if: inputs.dry_run == true + run: | + echo "=== DRY RUN COMPLETE ===" + echo "Branch: ${{ steps.branch.outputs.branch }}" + echo "Commits cherry-picked: ${{ steps.summary.outputs.picked }}" + echo "Commits skipped: ${{ steps.summary.outputs.skipped }}" + echo "Manual required: ${{ steps.summary.outputs.manual }}" + echo "" + echo "--- PR body preview ---" + echo "${{ steps.pr_body.outputs.body }}" diff --git a/.gitignore b/.gitignore index 7384b1a8ab..6b80b5f937 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,6 @@ dist # for vite vite.config.js.timestamp-* vite.config.ts.timestamp-* + +# upstream sync test output +tools/upstream-sync-output/ diff --git a/tools/test-upstream-sync.sh b/tools/test-upstream-sync.sh new file mode 100755 index 0000000000..ead3c5f7ab --- /dev/null +++ b/tools/test-upstream-sync.sh @@ -0,0 +1,429 @@ +#!/usr/bin/env bash +# +# Local end-to-end test for the upstream sync workflow. +# Mirrors .github/workflows/upstream-sync.yml but runs entirely locally +# using the Claude CLI instead of claude-code-action. +# +# Usage: +# bash tools/test-upstream-sync.sh # test with 5 oldest commits +# bash tools/test-upstream-sync.sh 1 # test with 1 oldest commit +# bash tools/test-upstream-sync.sh --all # test with all commits +# +# Prerequisites: +# - Claude CLI installed (claude) +# - gh CLI authenticated +# - jq installed +# - upstream remote configured (script will add it if missing) + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$REPO_ROOT" + +MAX_COMMITS=5 +if [ "${1:-}" = "--all" ]; then + MAX_COMMITS=0 +elif [ -n "${1:-}" ] && [[ "${1:-}" =~ ^[0-9]+$ ]]; then + MAX_COMMITS="$1" +fi + +ORIGINAL_BRANCH=$(git branch --show-current || git rev-parse --short HEAD) +TEST_BRANCH="upstream-sync/test-$(date +%s)" +OUTPUT_DIR="$REPO_ROOT/tools/upstream-sync-output" +mkdir -p "$OUTPUT_DIR" +LOG_FILE="$OUTPUT_DIR/test-run.log" +SUMMARY_FILE="$OUTPUT_DIR/sync-summary.json" +PROMPT_FILE="$OUTPUT_DIR/prompt.txt" +PR_TITLE_FILE="$OUTPUT_DIR/pr-title.txt" +PR_BODY_FILE="$OUTPUT_DIR/pr-body.md" + +# Log all output to file AND terminal +exec > >(tee -a "$LOG_FILE") 2>&1 +echo "Test started at $(date)" + +cleanup() { + echo "" + echo "=== Cleanup ===" + git cherry-pick --abort 2>/dev/null || true + git checkout "$ORIGINAL_BRANCH" 2>/dev/null || true + git branch -D "$TEST_BRANCH" 2>/dev/null || true + rm -f "$PROMPT_FILE" + echo "Restored branch: $ORIGINAL_BRANCH" + echo "Test branch $TEST_BRANCH deleted." + echo "Output files preserved in: $OUTPUT_DIR" +} +trap cleanup EXIT + +header() { + echo "" + echo "========================================" + echo " $1" + echo "========================================" +} + +# ── Step 1: Prerequisites ──────────────────────────────────────────── + +header "Step 1: Check prerequisites" + +for cmd in claude gh jq git; do + if ! command -v "$cmd" &>/dev/null; then + echo "ERROR: $cmd is not installed or not in PATH." + exit 1 + fi +done +echo " All tools available." + +# ── Step 2: Fetch upstream ─────────────────────────────────────────── + +header "Step 2: Fetch upstream master" + +if ! git remote get-url upstream &>/dev/null; then + echo " Adding upstream remote..." + git remote add upstream https://github.com/rrweb-io/rrweb.git +fi +git fetch upstream master --no-tags +echo " Done." + +# ── Step 3: Determine last sync point ──────────────────────────────── + +header "Step 3: Determine last sync point" + +FALLBACK="3e9e42fdfd6349087d7a0345af1b39dd56528502" +LAST_SYNC="" + +for STATE in open closed; do + TITLE=$(gh pr list \ + --label upstream-sync \ + --state "$STATE" \ + --limit 1 \ + --sort updated \ + --json title \ + --jq '.[0].title // empty' 2>/dev/null || true) + + echo " Checked $STATE PRs — title: '${TITLE:-}'" + + if [ -n "$TITLE" ]; then + LAST_SYNC=$(echo "$TITLE" | sed -n 's/.*\[last: \([0-9a-f]\{1,\}\)\].*/\1/p') + if [ -n "$LAST_SYNC" ]; then + echo " Found last sync hash from $STATE PR: $LAST_SYNC" + break + fi + fi +done + +if [ -z "$LAST_SYNC" ]; then + LAST_SYNC="$FALLBACK" + echo " No previous sync PR found; using fallback: $LAST_SYNC" +fi + +# ── Step 4: Collect upstream commits ───────────────────────────────── + +header "Step 4: Collect upstream commits" + +ALL_COMMITS=$(git log "${LAST_SYNC}..upstream/master" \ + --format='%H%x1f%h%x1f%s%x1f%an%x1f%ci' | jq -Rs ' + split("\n") + | map(select(length > 0)) + | map(split("\u001f")) + | map({ + hash: .[0], + short: .[1], + subject: .[2], + author: .[3], + date: .[4] + }) +') + +TOTAL_COUNT=$(echo "$ALL_COMMITS" | jq 'length') +LAST_UPSTREAM_HASH=$(echo "$ALL_COMMITS" | jq -r '.[0].hash // empty') + +echo " Found $TOTAL_COUNT total commits since $LAST_SYNC" +echo " Newest upstream commit: $LAST_UPSTREAM_HASH" + +if [ "$TOTAL_COUNT" -eq 0 ]; then + echo " No new upstream commits. Nothing to test." + exit 0 +fi + +if [ "$MAX_COMMITS" -gt 0 ] && [ "$TOTAL_COUNT" -gt "$MAX_COMMITS" ]; then + # Take the N oldest commits (they appear last in git log's newest-first output) + COMMITS=$(echo "$ALL_COMMITS" | jq ".[- $MAX_COMMITS:]") + SUBSET_COUNT=$(echo "$COMMITS" | jq 'length') + # The "last upstream hash" for the subset is the newest in the subset (first element) + LAST_UPSTREAM_HASH=$(echo "$COMMITS" | jq -r '.[0].hash // empty') + echo " Scoped to $SUBSET_COUNT oldest commits for testing (use --all for full run)." +else + COMMITS="$ALL_COMMITS" + SUBSET_COUNT="$TOTAL_COUNT" +fi + +echo "" +echo " Commits to process:" +echo "$COMMITS" | jq -r '.[] | " \(.short) — \(.subject)"' + +# ── Step 5: Create test branch ─────────────────────────────────────── + +header "Step 5: Create test branch" + +git checkout -b "$TEST_BRANCH" origin/master +git config user.name "test-upstream-sync" +git config user.email "test@localhost" +echo " Branch: $TEST_BRANCH" + +# ── Step 6: Invoke Claude CLI ──────────────────────────────────────── + +header "Step 6: Invoke Claude CLI for cherry-pick + triage" + +rm -f "$SUMMARY_FILE" + +cat > "$PROMPT_FILE" <\` +2. If it succeeds, move on. +3. If it fails with conflicts: + a. Use the Read tool to read each conflicting file (they will have conflict markers). + b. Use the Edit tool to resolve the conflicts, preserving Amplitude's custom logic + while incorporating the upstream fix. + c. Run: \`git add \` for each resolved file. + d. Run: \`git cherry-pick --continue --no-edit\` + e. If the conflict is too complex to resolve safely, run: + \`git cherry-pick --abort\` + and mark the commit as \`manual-required\`. + +## Output + +After processing all commits, write a JSON summary to \`$SUMMARY_FILE\`: +\`\`\`json +{ + "cherry_picked": [ + { + "hash": "abc1234", + "subject": "fix: textarea serialization", + "risk": "low", + "summary": "One-line description of what the fix does", + "conflict_resolved": false, + "conflict_resolution_note": null + } + ], + "skipped": [ + { + "hash": "def5678", + "subject": "chore: update CI config", + "reason": "CI-only change, not relevant to Amplitude fork" + } + ], + "manual_required": [ + { + "hash": "ghi9012", + "subject": "refactor: reorganize snapshot exports", + "reason": "Structural reorganization conflicts with Amplitude's export overrides" + } + ] +} +\`\`\` + +For any commit where you resolved a conflict, set \`conflict_resolved: true\` and +populate \`conflict_resolution_note\` with a brief explanation of what you decided +and why. + +If a conflict was resolved but you are uncertain about the decision, prefix the +note with \`[UNCERTAIN] \`. +PROMPT_EOF + +echo " Prompt written to $PROMPT_FILE ($(wc -l < "$PROMPT_FILE") lines)" +echo " Running Claude CLI... (this may take a minute)" +echo "" + +claude -p "$(cat "$PROMPT_FILE")" \ + --allowedTools Bash,Read,Edit + +# ── Step 7: Validate summary ──────────────────────────────────────── + +header "Step 7: Validate sync summary" + +if [ ! -f "$SUMMARY_FILE" ]; then + echo " ERROR: Claude did not produce $SUMMARY_FILE" + echo " The Claude step may have failed. Check output above." + exit 1 +fi + +echo " Summary file exists." +echo "" +cat "$SUMMARY_FILE" +echo "" + +PICKED=$(jq '.cherry_picked | length' "$SUMMARY_FILE") +SKIPPED=$(jq '.skipped | length' "$SUMMARY_FILE") +MANUAL=$(jq '.manual_required | length' "$SUMMARY_FILE") +HIGH_RISK=$(jq '[.cherry_picked[] | select(.risk == "high")] | length' "$SUMMARY_FILE") + +echo " Cherry-picked: $PICKED" +echo " Skipped: $SKIPPED" +echo " Manual: $MANUAL" +echo " High-risk: $HIGH_RISK" + +# Validate schema +for field in cherry_picked skipped manual_required; do + if ! echo "$(cat "$SUMMARY_FILE")" | jq -e ".$field" >/dev/null 2>&1; then + echo " ERROR: Missing required field: $field" + exit 1 + fi +done +echo " Schema valid." + +# ── Step 8: Build PR title and body ───────────────────────────────── + +header "Step 8: Build PR title and body" + +DATE=$(date +%Y-%m-%d) +TITLE="chore: upstream rrweb sync $DATE ($PICKED commit(s)) [last: $LAST_UPSTREAM_HASH]" + +echo "$TITLE" > "$PR_TITLE_FILE" +echo " PR title written to: $PR_TITLE_FILE" + +# Verify round-trip +PARSED_HASH=$(echo "$TITLE" | sed -n 's/.*\[last: \([0-9a-f]\{1,\}\)\].*/\1/p') +if [ "$PARSED_HASH" = "$LAST_UPSTREAM_HASH" ]; then + echo " Round-trip parse: PASS" +else + echo " Round-trip parse: FAIL ($PARSED_HASH != $LAST_UPSTREAM_HASH)" +fi + +SUMMARY_JSON=$(cat "$SUMMARY_FILE") + +ROWS=$(echo "$SUMMARY_JSON" | jq -r ' + .cherry_picked[] | + "| `\(.hash[0:7])` | \(.subject) | `\(.risk)` | \(if .conflict_resolved then "⚠️ " else "" end)\(.summary) |" +') + +SKIPPED_ROWS=$(echo "$SUMMARY_JSON" | jq -r ' + .skipped[] | + "| `\(.hash[0:7])` | \(.subject) | \(.reason) |" +') + +MANUAL_ROWS=$(echo "$SUMMARY_JSON" | jq -r ' + .manual_required[] | + "| `\(.hash[0:7])` | \(.subject) | \(.reason) |" +') + +CONFLICT_NOTES=$(echo "$SUMMARY_JSON" | jq -r ' + .cherry_picked[] | select(.conflict_resolved == true) | + "- **`\(.hash[0:7])`** \(.subject)\n \(.conflict_resolution_note)" +') + +{ + echo "## Upstream rrweb sync — $DATE" + echo "" + echo "Automatically cherry-picked **$PICKED** upstream commit(s) from [rrweb-io/rrweb](https://github.com/rrweb-io/rrweb)." + echo "" + echo "**Sync range:** \`$LAST_SYNC\` → \`$LAST_UPSTREAM_HASH\`" + echo "" + echo "### Cherry-picked commits" + echo "" + echo "| Commit | Subject | Risk | Notes |" + echo "|--------|---------|------|-------|" + echo "$ROWS" + echo "" + + if [ -n "$CONFLICT_NOTES" ]; then + echo "### ⚠️ Auto-resolved conflicts" + echo "" + echo "The following commits had conflicts that were resolved automatically." + echo "Give these extra scrutiny before merging." + echo "" + echo -e "$CONFLICT_NOTES" + echo "" + fi + + MANUAL_COUNT=$(echo "$SUMMARY_JSON" | jq '.manual_required | length') + if [ "$MANUAL_COUNT" -gt 0 ]; then + echo "### Manual cherry-pick required" + echo "" + echo "| Commit | Subject | Reason |" + echo "|--------|---------|--------|" + echo "$MANUAL_ROWS" + echo "" + fi + + SKIPPED_COUNT=$(echo "$SUMMARY_JSON" | jq '.skipped | length') + if [ "$SKIPPED_COUNT" -gt 0 ]; then + echo "
" + echo "Skipped commits ($SKIPPED_COUNT)" + echo "" + echo "| Commit | Subject | Reason |" + echo "|--------|---------|--------|" + echo "$SKIPPED_ROWS" + echo "" + echo "
" + fi + + echo "" + echo "---" + echo "_Generated by [Upstream Sync Monitor](/.github/workflows/upstream-sync.yml)_" +} > "$PR_BODY_FILE" + +echo " PR body written to: $PR_BODY_FILE" + +# ── Step 9: Git log of cherry-picked commits on the branch ────────── + +header "Step 9: Verify git history" + +echo " Commits on test branch (beyond origin/master):" +git log origin/master..HEAD --oneline | while read -r line; do + echo " $line" +done + +# ── Done ───────────────────────────────────────────────────────────── + +header "Test complete" + +echo " Results:" +echo " Commits evaluated: $SUBSET_COUNT" +echo " Cherry-picked: $PICKED" +echo " Skipped: $SKIPPED" +echo " Manual required: $MANUAL" +echo " High-risk: $HIGH_RISK" +echo " Last upstream hash: $LAST_UPSTREAM_HASH" +echo " Round-trip parse: $([ "$PARSED_HASH" = "$LAST_UPSTREAM_HASH" ] && echo PASS || echo FAIL)" +echo "" +echo " Output files:" +echo " $SUMMARY_FILE" +echo " $PR_TITLE_FILE" +echo " $PR_BODY_FILE" +echo "" +echo " Cleanup will run automatically..."