@@ -24,6 +24,33 @@ def _write_stub_gh(path: Path) -> None:
2424shift
2525shift || true
2626
27+ next_sequence_value() {
28+ local sequence="$1"
29+ local default_value="$2"
30+ local counter_key="$3"
31+ local state_dir="${GH_STATE_DIR:?GH_STATE_DIR is required}"
32+ local index_file="$state_dir/$counter_key.idx"
33+ local index=0
34+
35+ if [[ -z "$sequence" ]]; then
36+ printf '%s\\ n' "$default_value"
37+ return
38+ fi
39+
40+ if [[ -f "$index_file" ]]; then
41+ index="$(cat "$index_file")"
42+ fi
43+
44+ IFS=',' read -r -a values <<< "$sequence"
45+ if ((index < ${#values[@]})); then
46+ printf '%s\\ n' "${values[$index]}"
47+ else
48+ printf '%s\\ n' "${values[$((${#values[@]} - 1))]}"
49+ fi
50+
51+ printf '%s\\ n' $((index + 1)) > "$index_file"
52+ }
53+
2754if [[ "$cmd" == "pr" && "$subcmd" == "view" ]]; then
2855 jq_expr=""
2956 while [[ $# -gt 0 ]]; do
@@ -37,7 +64,7 @@ def _write_stub_gh(path: Path) -> None:
3764 ".state") printf '%s\\ n' "${GH_PR_STATE:-OPEN}" ;;
3865 ".isDraft") printf '%s\\ n' "${GH_PR_DRAFT:-false}" ;;
3966 ".autoMergeRequest != null") printf '%s\\ n' "${GH_PR_AUTO_MERGE:-false}" ;;
40- ".headRefOid") printf '%s \\ n' "${GH_PR_HEAD_OID:-head-sha}" ;;
67+ ".headRefOid") next_sequence_value "${GH_PR_HEAD_OID_SEQUENCE:-}" "${GH_PR_HEAD_OID:-head-sha}" "head_ref_oid " ;;
4168 *) exit 1 ;;
4269 esac
4370 exit 0
@@ -79,7 +106,7 @@ def _write_stub_gh(path: Path) -> None:
79106if [[ "$cmd" == "pr" && "$subcmd" == "checks" ]]; then
80107 printf '%s\\ n' "pr checks $*" >> "${GH_LOG_PATH:?GH_LOG_PATH is required}"
81108 if [[ "$*" == *"--json name --jq length"* ]]; then
82- printf '%s \\ n' "${GH_REQUIRED_CHECKS_COUNT:-1}"
109+ next_sequence_value "${GH_REQUIRED_CHECKS_COUNT_SEQUENCE:-}" "${GH_REQUIRED_CHECKS_COUNT:-1}" "required_checks_count "
83110 exit 0
84111 fi
85112 if [[ "$*" == *"--watch"* ]]; then
@@ -108,6 +135,7 @@ def _run_script(tmp_path: Path, extra_env: dict[str, str]) -> subprocess.Complet
108135 "GITHUB_REPOSITORY" : "wallstop/fortress-rollback" ,
109136 "GH_TOKEN" : "fake-token" ,
110137 "GH_LOG_PATH" : str (log_path ),
138+ "GH_STATE_DIR" : str (tmp_path ),
111139 "REQUIRED_CHECKS_APPEAR_TIMEOUT_SECONDS" : "0" ,
112140 "REQUIRED_CHECKS_POLL_INTERVAL_SECONDS" : "1" ,
113141 "REQUIRED_CHECKS_WATCH_INTERVAL_SECONDS" : "1" ,
@@ -213,3 +241,115 @@ def test_fails_when_required_checks_fail(tmp_path: Path) -> None:
213241 assert "--json name --jq length" in log_lines [0 ]
214242 assert "--watch" in log_lines [1 ]
215243 assert "pr merge" not in "\n " .join (log_lines )
244+
245+
246+ def test_waits_for_required_checks_to_appear_then_merges (tmp_path : Path ) -> None :
247+ result = _run_script (
248+ tmp_path ,
249+ {
250+ "GH_REQUIRED_CHECKS_COUNT_SEQUENCE" : "0,0,1" ,
251+ "GH_MERGE_SUCCESS_FLAG" : "--squash" ,
252+ "GH_ALLOW_SQUASH" : "true" ,
253+ "GH_ALLOW_REBASE" : "false" ,
254+ "GH_ALLOW_MERGE" : "false" ,
255+ "REQUIRED_CHECKS_APPEAR_TIMEOUT_SECONDS" : "3" ,
256+ "REQUIRED_CHECKS_POLL_INTERVAL_SECONDS" : "1" ,
257+ },
258+ )
259+ assert result .returncode == 0
260+
261+ log_lines = (tmp_path / "gh.log" ).read_text (encoding = "utf-8" ).splitlines ()
262+ assert len (log_lines ) == 5
263+ assert "--json name --jq length" in log_lines [0 ]
264+ assert "--json name --jq length" in log_lines [1 ]
265+ assert "--json name --jq length" in log_lines [2 ]
266+ assert "--watch" in log_lines [3 ]
267+ assert "--squash" in log_lines [4 ]
268+
269+
270+ def test_skips_when_head_becomes_stale_while_waiting (tmp_path : Path ) -> None :
271+ result = _run_script (
272+ tmp_path ,
273+ {
274+ "GH_REQUIRED_CHECKS_COUNT_SEQUENCE" : "0,0,1" ,
275+ "GH_PR_HEAD_OID_SEQUENCE" : "head-sha,head-sha,new-head-sha" ,
276+ "GH_ALLOW_SQUASH" : "true" ,
277+ "GH_ALLOW_REBASE" : "false" ,
278+ "GH_ALLOW_MERGE" : "false" ,
279+ "REQUIRED_CHECKS_APPEAR_TIMEOUT_SECONDS" : "3" ,
280+ "REQUIRED_CHECKS_POLL_INTERVAL_SECONDS" : "1" ,
281+ },
282+ )
283+ assert result .returncode == 0
284+ assert "PR head moved while waiting for required checks" in result .stdout
285+
286+ log_lines = (tmp_path / "gh.log" ).read_text (encoding = "utf-8" ).splitlines ()
287+ assert len (log_lines ) == 1
288+ assert "--json name --jq length" in log_lines [0 ]
289+ assert "--watch" not in log_lines [0 ]
290+ assert "pr merge" not in log_lines [0 ]
291+
292+
293+ def test_skips_when_head_becomes_stale_after_checks_appear (tmp_path : Path ) -> None :
294+ result = _run_script (
295+ tmp_path ,
296+ {
297+ "GH_REQUIRED_CHECKS_COUNT_SEQUENCE" : "1" ,
298+ "GH_PR_HEAD_OID_SEQUENCE" : "head-sha,head-sha,new-head-sha" ,
299+ "GH_ALLOW_SQUASH" : "true" ,
300+ "GH_ALLOW_REBASE" : "false" ,
301+ "GH_ALLOW_MERGE" : "false" ,
302+ },
303+ )
304+ assert result .returncode == 0
305+ assert "PR head moved after required checks appeared" in result .stdout
306+
307+ log_lines = (tmp_path / "gh.log" ).read_text (encoding = "utf-8" ).splitlines ()
308+ assert len (log_lines ) == 1
309+ assert "--json name --jq length" in log_lines [0 ]
310+ assert "--watch" not in log_lines [0 ]
311+ assert "pr merge" not in log_lines [0 ]
312+
313+
314+ def test_skips_when_head_becomes_stale_after_checks_complete (tmp_path : Path ) -> None :
315+ result = _run_script (
316+ tmp_path ,
317+ {
318+ "GH_REQUIRED_CHECKS_COUNT_SEQUENCE" : "1" ,
319+ "GH_PR_HEAD_OID_SEQUENCE" : "head-sha,head-sha,head-sha,new-head-sha" ,
320+ "GH_ALLOW_SQUASH" : "true" ,
321+ "GH_ALLOW_REBASE" : "false" ,
322+ "GH_ALLOW_MERGE" : "false" ,
323+ },
324+ )
325+ assert result .returncode == 0
326+ assert "PR head moved after required checks completed" in result .stdout
327+
328+ log_lines = (tmp_path / "gh.log" ).read_text (encoding = "utf-8" ).splitlines ()
329+ assert len (log_lines ) == 2
330+ assert "--json name --jq length" in log_lines [0 ]
331+ assert "--watch" in log_lines [1 ]
332+ assert "pr merge" not in "\n " .join (log_lines )
333+
334+
335+ def test_caps_poll_sleep_to_remaining_timeout (tmp_path : Path ) -> None :
336+ result = _run_script (
337+ tmp_path ,
338+ {
339+ "GH_REQUIRED_CHECKS_COUNT_SEQUENCE" : "0,1" ,
340+ "GH_MERGE_SUCCESS_FLAG" : "--squash" ,
341+ "GH_ALLOW_SQUASH" : "true" ,
342+ "GH_ALLOW_REBASE" : "false" ,
343+ "GH_ALLOW_MERGE" : "false" ,
344+ "REQUIRED_CHECKS_APPEAR_TIMEOUT_SECONDS" : "1" ,
345+ "REQUIRED_CHECKS_POLL_INTERVAL_SECONDS" : "10" ,
346+ },
347+ )
348+ assert result .returncode == 0
349+
350+ log_lines = (tmp_path / "gh.log" ).read_text (encoding = "utf-8" ).splitlines ()
351+ assert len (log_lines ) == 4
352+ assert "--json name --jq length" in log_lines [0 ]
353+ assert "--json name --jq length" in log_lines [1 ]
354+ assert "--watch" in log_lines [2 ]
355+ assert "--squash" in log_lines [3 ]
0 commit comments