Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ This action tries to fix that in a transparent way. Install it, and hopefully th
4. Pushes the updated branches
5. Updates the direct child PRs to base on trunk now that the bottom change has landed
6. Deletes the merged branch
7. Checks that GitHub actually serves each updated PR's diff consistently with the refs. After a base change, GitHub sometimes keeps serving the diff computed against the old base until a fresh event on the PR triggers a recompute, so the "Files changed" tab would durably show the parent's changes. On a mismatch the action re-asserts the PR's current base (a same-value edit that feeds the recompute a fresh event) and re-checks a few times; if the diff stays stale it posts a comment on the PR with the manual fix. This step is advisory and never fails the run.

The re-parent primitive ([git-merge-onto](https://github.com/scortexio/git-merge-onto)) is vendored as a single zero-dependency file and run with `python3`, so the action needs no network download.

Expand Down
36 changes: 35 additions & 1 deletion tests/mock_gh.sh
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,44 @@ if [[ "$1" == "api" && "$2" == repos/*"/pulls?base="* ]]; then
# No other bases have direct children in our test scenario
:
fi
elif [[ "$1" == "pr" && "$2" == "view" && "$*" == *"--json baseRefName"* ]]; then
# Only the served-diff verification asks; by then the PR has been
# retargeted onto the target branch.
echo "${TARGET_BRANCH:-main}"
elif [[ "$1" == "pr" && "$2" == "diff" ]]; then
# The diff GitHub serves for the PR. While the MOCK_STALE_DIFFS file
# exists, serve garbage instead, simulating the staleness bug the
# verification pass works around; see `pr edit` below for how it heals.
if [[ -n "${MOCK_STALE_DIFFS:-}" && -e "$MOCK_STALE_DIFFS" ]]; then
echo "stale diff from before the base update"
elif [[ "$3" == 2 ]]; then
# PR #2 is feature2 (see the pulls query above). Neutral config so
# the output has the shape GitHub serves, whatever the test machine's
# gitconfig says.
GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null \
git diff "origin/${TARGET_BRANCH:-main}...origin/feature2"
else
echo "Unknown PR number for pr diff: $3" >&2
exit 1
fi
elif [[ "$1" == "pr" && "$2" == "edit" ]]; then
# Just log the edit command
# A PR event feeds GitHub's diff recompute a fresh chance to run: count
# down the edits the staleness survives, then heal the served diff.
if [[ -n "${MOCK_STALE_DIFFS:-}" && -e "$MOCK_STALE_DIFFS" ]]; then
N=$(cat "$MOCK_STALE_DIFFS")
if [[ "$N" -gt 0 ]]; then
echo "$((N - 1))" > "$MOCK_STALE_DIFFS"
else
rm "$MOCK_STALE_DIFFS"
fi
fi
echo "Mock: gh pr edit $3 --base $5"
elif [[ "$1" == "pr" && "$2" == "comment" ]]; then
# A `-F -` body arrives on stdin; consume it so the writer does not die
# on a broken pipe.
if [[ "$*" == *"-F"* ]]; then
cat > /dev/null
fi
# Just log the comment command
echo "Mock: gh pr comment $3"
elif [[ "$1" == "api" && "$2" == repos/*/commits/*/pulls ]]; then
Expand Down
6 changes: 4 additions & 2 deletions tests/mock_git.sh
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
#!/bin/bash


if [[ "$1" == "push" ]]; then
# Log the attempt but don't execute, preventing failure
if [[ "$1" == "push" || "$1" == "fetch" ]]; then
# Log the attempt but don't execute: the test repos have no real remote.
# push would fail outright; refs/remotes/origin/* stand for the remote's
# state and are maintained by the tests' simulate_push instead.
printf "Executing (mocked):" >&2
printf " %q" "git" "$@" >&2
printf "\n" >&2
Expand Down
19 changes: 15 additions & 4 deletions tests/test_conflict_resolution_resume.sh
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,10 @@ ok() { echo "✅ $1"; PASS=$((PASS+1)); }
# MOCK_COMMENTS_FILE file served as the body of our own PR comments
# MOCK_LABELS_FAIL set to 1 to make `pr view --json labels` fail
# MOCK_PR_LIST_FAIL set to 1 to make `pr list` fail
# The PR's base branch is not mocked: the script must take it from PR_BASE
# (event payload), so a baseRefName query is an unhandled call and fails.
# The resume decision must take the PR's base from PR_BASE (event payload),
# never from a live baseRefName query; only the served-diff verification at
# the very end of a successful resume may ask, and by then the PR has been
# retargeted onto main.
make_mock_gh() {
local dir="$1"
cat > "$dir/mock_gh.sh" <<'EOF'
Expand All @@ -37,8 +39,16 @@ if [[ "$1 $2" == "pr view" ]]; then
*--json\ labels*)
[[ "${MOCK_LABELS_FAIL:-}" == 1 ]] && { echo "mock gh: labels API down" >&2; exit 1; }
printf '%s\n' "${MOCK_LABELS:-}";;
*--json\ baseRefName*)
echo main;;
*) echo "unhandled pr view: $*" >&2; exit 1;;
esac
elif [[ "$1 $2" == "pr diff" ]]; then
# The diff GitHub serves for the resumed PR (#5, branch child). Neutral
# config so the output has the shape GitHub serves, whatever the test
# machine's gitconfig says.
GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null \
git diff origin/main...origin/child
elif [[ "$1 $2" == "api graphql" ]]; then
# The comments file stands for our own comments only, so the query must
# restrict itself to those.
Expand Down Expand Up @@ -98,7 +108,7 @@ setup_repo() {

run_resume() {
env ACTION_MODE=conflict-resolved PR_BRANCH=child PR_NUMBER=5 PR_BASE="$PR_BASE" \
GITHUB_REPOSITORY=tester/repo \
GITHUB_REPOSITORY=tester/repo VERIFY_POLL_SECONDS=0 \
GH="$MOCK_DIR/mock_gh.sh" GIT="$MOCK_DIR/mock_git.sh" \
MOCK_LABELS="$MOCK_LABELS" MOCK_LABELS_FAIL="${MOCK_LABELS_FAIL:-}" \
MOCK_COMMENTS_FILE="$MOCK_COMMENTS_FILE" CALLS="$CALLS" \
Expand Down Expand Up @@ -161,7 +171,8 @@ base_line=$(grep -n -- "--base main" "$CALLS" | head -1 | cut -d: -f1)
label_line=$(grep -n "remove-label" "$CALLS" | head -1 | cut -d: -f1)
[[ "$push_line" -lt "$base_line" ]] || fail "C: push must come before base edit"
[[ "$base_line" -lt "$label_line" ]] || fail "C: base edit must come before label removal"
ok "C: resume pushes, retargets base, then removes label"
grep -q "✓ GitHub serves the expected diff for PR #5" "$WORK/out.log" || fail "C: served diff not verified"
ok "C: resume pushes, retargets base, removes label, verifies the served diff"

# ---------------------------------------------------------------------------
echo "### Scenario D: recorded target branch is gone -> give up cleanly"
Expand Down
12 changes: 12 additions & 0 deletions tests/test_merge_commit_merge.sh
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,18 @@ if [[ "$EDIT_LINE" -gt "$DELETE_LINE" ]]; then
exit 1
fi

# The served-diff verification is advisory, so it must come after the
# deletion, the last mutation the run owes the stack.
VERIFY_LINE=$(grep -n "✓ GitHub serves the expected diff for PR #2" <<<"$OUT" | cut -d: -f1 | head -1 || true)
if [[ -z "$VERIFY_LINE" ]]; then
echo "❌ Served diff must be verified after the retarget"
exit 1
fi
if [[ "$VERIFY_LINE" -lt "$DELETE_LINE" ]]; then
echo "❌ Verification must come after the branch deletion (verify=$VERIFY_LINE delete=$DELETE_LINE)"
exit 1
fi

# The untouched head keeps a clean diff against the new base
ACTUAL_DIFF=$(git diff main...feature2 | grep '^[+-]' | grep -v '^[+-][+-][+-]')
if [[ "$ACTUAL_DIFF" != "+f2" ]]; then
Expand Down
77 changes: 77 additions & 0 deletions tests/test_update_pr_stack.sh
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,16 @@ else
exit 1
fi

# The run must end by checking the diff GitHub serves for the retargeted PR
# against the refs (the mock serves a diff computed from the same refs, so
# they match).
if grep -q "✓ GitHub serves the expected diff for PR #2" "$RUN_LOG"; then
echo "✅ Served diff verified against the refs"
else
echo "❌ Served-diff verification did not pass"
exit 1
fi

# Verify the results
cd "$TEST_REPO"

Expand Down Expand Up @@ -212,6 +222,73 @@ else
exit 1
fi

# GitHub sometimes keeps serving a retargeted PR's diff computed against the
# old base until a fresh event triggers a recompute. The mock simulates it
# with a countdown file: `pr diff` serves garbage while the file exists, and
# each `pr edit` decrements it, clearing it at zero. With a count of 1 the
# initial retarget leaves the diff stale and the verification's nudge (a
# same-value base edit) heals it.
echo -e "\nRunning update script with a stale served diff (heals on the nudge)..."
STALE_FILE="$TEST_REPO/stale_diffs"
echo 1 > "$STALE_FILE"
STALE_LOG=$(mktemp)
log_cmd \
env \
SQUASH_COMMIT=$SQUASH_COMMIT \
MERGED_BRANCH=feature1 \
PR_NUMBER=1 \
TARGET_BRANCH=main \
MOCK_STALE_DIFFS="$STALE_FILE" \
VERIFY_POLL_SECONDS=0 \
GH="$SCRIPT_DIR/mock_gh.sh" \
GIT="$SCRIPT_DIR/mock_git.sh" \
$SCRIPT_DIR/../update-pr-stack.sh 2>&1 | tee "$STALE_LOG"

NUDGES=$(grep -c "Mock: gh pr edit 2 --base main" "$STALE_LOG" || true)
if [[ "$NUDGES" -eq 2 ]]; then
echo "✅ Stale diff nudged with a same-value base edit"
else
echo "❌ Expected the retarget plus one nudge, saw $NUDGES base edits"
exit 1
fi
if grep -q "✓ GitHub serves the expected diff for PR #2" "$STALE_LOG"; then
echo "✅ Stale served diff healed after the nudge"
else
echo "❌ Served diff still unverified after the nudge"
exit 1
fi
if grep -q "Mock: gh pr comment" "$STALE_LOG"; then
echo "❌ No comment must be posted when the nudge heals the diff"
exit 1
fi

# When the staleness survives the nudge and every re-check, the run must warn
# the user with a PR comment and still end green: the stack update itself
# succeeded.
echo -e "\nRunning update script with a served diff that never heals..."
echo 99 > "$STALE_FILE"
STUCK_LOG=$(mktemp)
log_cmd \
env \
SQUASH_COMMIT=$SQUASH_COMMIT \
MERGED_BRANCH=feature1 \
PR_NUMBER=1 \
TARGET_BRANCH=main \
MOCK_STALE_DIFFS="$STALE_FILE" \
VERIFY_POLL_SECONDS=0 \
GH="$SCRIPT_DIR/mock_gh.sh" \
GIT="$SCRIPT_DIR/mock_git.sh" \
$SCRIPT_DIR/../update-pr-stack.sh 2>&1 | tee "$STUCK_LOG"

if grep -q "still serves a stale diff for PR #2" "$STUCK_LOG" \
&& grep -q "Mock: gh pr comment 2" "$STUCK_LOG"; then
echo "✅ Persistent staleness warns and comments on the PR without failing the run"
else
echo "❌ Persistent staleness must warn and comment on the PR"
exit 1
fi
rm -f "$STALE_FILE"

# A failed children listing must fail the run before any mutation: silently
# treating it as "no children" would delete the merged branch under the
# children it never saw.
Expand Down
Loading
Loading