diff --git a/Resources/shell-integration/cmux-bash-integration.bash b/Resources/shell-integration/cmux-bash-integration.bash index 2395b7f9..cbb3f103 100644 --- a/Resources/shell-integration/cmux-bash-integration.bash +++ b/Resources/shell-integration/cmux-bash-integration.bash @@ -125,44 +125,65 @@ _cmux_clear_pr_for_panel() { _cmux_send "clear_pr --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID" } +_cmux_pr_output_indicates_no_pull_request() { + local output="$1" + output="$(printf '%s' "$output" | tr '[:upper:]' '[:lower:]')" + [[ "$output" == *"no pull requests found"* \ + || "$output" == *"no pull request found"* \ + || "$output" == *"no pull requests associated"* \ + || "$output" == *"no pull request associated"* ]] +} + _cmux_report_pr_for_path() { local repo_path="$1" [[ -n "$repo_path" ]] || { _cmux_clear_pr_for_panel return 0 } + [[ -d "$repo_path" ]] || { + _cmux_clear_pr_for_panel + return 0 + } [[ -S "$CMUX_SOCKET_PATH" ]] || return 0 [[ -n "$CMUX_TAB_ID" ]] || return 0 [[ -n "$CMUX_PANEL_ID" ]] || return 0 - local branch pr_tsv gh_status number state url status_opt="" + local branch gh_output gh_error="" err_file="" gh_status number state url status_opt="" branch="$(git -C "$repo_path" branch --show-current 2>/dev/null)" if [[ -z "$branch" ]] || ! command -v gh >/dev/null 2>&1; then _cmux_clear_pr_for_panel return 0 fi - pr_tsv="$( + err_file="$(/usr/bin/mktemp "${TMPDIR:-/tmp}/cmux-gh-pr-view.XXXXXX" 2>/dev/null || true)" + [[ -n "$err_file" ]] || return 1 + gh_output="$( builtin cd "$repo_path" 2>/dev/null \ - && gh pr list \ - --head "$branch" \ - --state all \ - --json number,state,url,updatedAt \ - --jq 'if length == 0 then "" else (sort_by(.updatedAt) | last | [.number, .state, .url] | @tsv) end' \ - 2>/dev/null + && gh pr view \ + --json number,state,url \ + --jq '[.number, .state, .url] | @tsv' \ + 2>"$err_file" )" gh_status=$? + if [[ -f "$err_file" ]]; then + gh_error="$("/bin/cat" -- "$err_file" 2>/dev/null || true)" + /bin/rm -f -- "$err_file" >/dev/null 2>&1 || true + fi if (( gh_status != 0 )); then + if _cmux_pr_output_indicates_no_pull_request "$gh_error"; then + _cmux_clear_pr_for_panel + return 0 + fi # Preserve the last-known PR badge when gh fails transiently, then retry # on the next background poll instead of clearing visible state. return 1 fi - if [[ -z "$pr_tsv" ]]; then + if [[ -z "$gh_output" ]]; then _cmux_clear_pr_for_panel return 0 fi - IFS=$'\t' read -r number state url <<< "$pr_tsv" + IFS=$'\t' read -r number state url <<< "$gh_output" if [[ -z "$number" || -z "$url" ]]; then return 1 fi @@ -177,6 +198,37 @@ _cmux_report_pr_for_path() { _cmux_send "report_pr $number $url $status_opt --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID" } +_cmux_run_pr_probe_with_timeout() { + local repo_path="$1" + local probe_pid="" + local started_at=$SECONDS + local now=$started_at + + ( + _cmux_report_pr_for_path "$repo_path" + ) & + probe_pid=$! + + while kill -0 "$probe_pid" >/dev/null 2>&1; do + sleep 1 + now=$SECONDS + if (( _CMUX_ASYNC_JOB_TIMEOUT > 0 )) && (( now - started_at >= _CMUX_ASYNC_JOB_TIMEOUT )); then + kill "$probe_pid" >/dev/null 2>&1 || true + sleep 0.2 + if kill -0 "$probe_pid" >/dev/null 2>&1; then + kill -9 "$probe_pid" >/dev/null 2>&1 || true + sleep 0.2 + fi + if ! kill -0 "$probe_pid" >/dev/null 2>&1; then + wait "$probe_pid" >/dev/null 2>&1 || true + fi + return 1 + fi + done + + wait "$probe_pid" +} + _cmux_stop_pr_poll_loop() { if [[ -n "$_CMUX_PR_POLL_PID" ]]; then kill "$_CMUX_PR_POLL_PID" >/dev/null 2>&1 || true @@ -205,7 +257,7 @@ _cmux_start_pr_poll_loop() { { while :; do kill -0 "$watch_shell_pid" 2>/dev/null || break - _cmux_report_pr_for_path "$watch_pwd" || true + _cmux_run_pr_probe_with_timeout "$watch_pwd" || true sleep "$interval" done } >/dev/null 2>&1 & @@ -221,6 +273,7 @@ _cmux_preexec_trap() { (( _CMUX_IN_PROMPT_COMMAND )) && return 0 (( _CMUX_PREEXEC_READY )) || return 0 _CMUX_PREEXEC_READY=0 + _cmux_stop_pr_poll_loop local cmd="${BASH_COMMAND## }" case "$cmd" in @@ -337,6 +390,12 @@ _cmux_prompt_command() { # Pull request metadata is remote state. Keep polling while the shell sits # at a prompt so newly created or merged PRs appear without another command. local should_restart_pr_poll=0 + local pr_context_changed=0 + if [[ -n "$_CMUX_PR_POLL_PWD" && "$pwd" != "$_CMUX_PR_POLL_PWD" ]]; then + pr_context_changed=1 + elif [[ "$git_head_changed" == "1" ]]; then + pr_context_changed=1 + fi if [[ "$pwd" != "$_CMUX_PR_POLL_PWD" || "$git_head_changed" == "1" ]]; then should_restart_pr_poll=1 elif (( _CMUX_PR_FORCE )); then @@ -347,6 +406,9 @@ _cmux_prompt_command() { if (( should_restart_pr_poll )); then _CMUX_PR_FORCE=0 + if (( pr_context_changed )); then + _cmux_clear_pr_for_panel + fi _cmux_start_pr_poll_loop "$pwd" 1 fi diff --git a/Resources/shell-integration/cmux-zsh-integration.zsh b/Resources/shell-integration/cmux-zsh-integration.zsh index 763d14e9..b6b82c88 100644 --- a/Resources/shell-integration/cmux-zsh-integration.zsh +++ b/Resources/shell-integration/cmux-zsh-integration.zsh @@ -147,45 +147,65 @@ _cmux_clear_pr_for_panel() { _cmux_send "clear_pr --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID" } +_cmux_pr_output_indicates_no_pull_request() { + local output="${1:l}" + [[ "$output" == *"no pull requests found"* \ + || "$output" == *"no pull request found"* \ + || "$output" == *"no pull requests associated"* \ + || "$output" == *"no pull request associated"* ]] +} + _cmux_report_pr_for_path() { local repo_path="$1" [[ -n "$repo_path" ]] || { _cmux_clear_pr_for_panel return 0 } + [[ -d "$repo_path" ]] || { + _cmux_clear_pr_for_panel + return 0 + } [[ -S "$CMUX_SOCKET_PATH" ]] || return 0 [[ -n "$CMUX_TAB_ID" ]] || return 0 [[ -n "$CMUX_PANEL_ID" ]] || return 0 - local branch pr_tsv number state url status_opt="" gh_status + local branch gh_output gh_error="" err_file="" number state url status_opt="" gh_status branch="$(git -C "$repo_path" branch --show-current 2>/dev/null)" if [[ -z "$branch" ]] || ! command -v gh >/dev/null 2>&1; then _cmux_clear_pr_for_panel return 0 fi - pr_tsv="$( + err_file="$(/usr/bin/mktemp "${TMPDIR:-/tmp}/cmux-gh-pr-view.XXXXXX" 2>/dev/null || true)" + [[ -n "$err_file" ]] || return 1 + gh_output="$( builtin cd "$repo_path" 2>/dev/null \ - && gh pr list \ - --head "$branch" \ - --state all \ - --json number,state,url,updatedAt \ - --jq 'if length == 0 then "" else (sort_by(.updatedAt) | last | [.number, .state, .url] | @tsv) end' \ - 2>/dev/null + && gh pr view \ + --json number,state,url \ + --jq '[.number, .state, .url] | @tsv' \ + 2>"$err_file" )" gh_status=$? + if [[ -f "$err_file" ]]; then + gh_error="$("/bin/cat" -- "$err_file" 2>/dev/null || true)" + /bin/rm -f -- "$err_file" >/dev/null 2>&1 || true + fi if (( gh_status != 0 )); then + if _cmux_pr_output_indicates_no_pull_request "$gh_error"; then + _cmux_clear_pr_for_panel + return 0 + fi # Keep the last-known PR badge on transient gh failures (auth hiccups, # API lag after creation, or rate limiting) and retry on the next poll. return 1 fi - if [[ -z "$pr_tsv" ]]; then + if [[ -z "$gh_output" ]]; then _cmux_clear_pr_for_panel return 0 fi local IFS=$'\t' - read -r number state url <<< "$pr_tsv" + read -r number state url <<< "$gh_output" if [[ -z "$number" ]] || [[ -z "$url" ]]; then return 1 fi @@ -200,6 +220,37 @@ _cmux_report_pr_for_path() { _cmux_send "report_pr $number $url $status_opt --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID" } +_cmux_run_pr_probe_with_timeout() { + local repo_path="$1" + local probe_pid="" + local started_at=$EPOCHSECONDS + local now=$started_at + + ( + _cmux_report_pr_for_path "$repo_path" + ) & + probe_pid=$! + + while kill -0 "$probe_pid" >/dev/null 2>&1; do + sleep 1 + now=$EPOCHSECONDS + if (( _CMUX_ASYNC_JOB_TIMEOUT > 0 )) && (( now - started_at >= _CMUX_ASYNC_JOB_TIMEOUT )); then + kill "$probe_pid" >/dev/null 2>&1 || true + sleep 0.2 + if kill -0 "$probe_pid" >/dev/null 2>&1; then + kill -9 "$probe_pid" >/dev/null 2>&1 || true + sleep 0.2 + fi + if ! kill -0 "$probe_pid" >/dev/null 2>&1; then + wait "$probe_pid" >/dev/null 2>&1 || true + fi + return 1 + fi + done + + wait "$probe_pid" +} + _cmux_stop_pr_poll_loop() { if [[ -n "$_CMUX_PR_POLL_PID" ]]; then kill "$_CMUX_PR_POLL_PID" >/dev/null 2>&1 || true @@ -228,7 +279,7 @@ _cmux_start_pr_poll_loop() { { while true; do kill -0 "$watch_shell_pid" >/dev/null 2>&1 || break - _cmux_report_pr_for_path "$watch_pwd" || true + _cmux_run_pr_probe_with_timeout "$watch_pwd" || true sleep "$interval" done } >/dev/null 2>&1 &! @@ -297,6 +348,7 @@ _cmux_preexec() { # Register TTY + kick batched port scan for foreground commands (servers). _cmux_report_tty_once _cmux_ports_kick + _cmux_stop_pr_poll_loop _cmux_start_git_head_watch } @@ -350,6 +402,7 @@ _cmux_precmd() { # While a foreground command is running, _cmux_start_git_head_watch probes HEAD # once per second so agent-initiated git checkouts still surface quickly. local should_git=0 + local git_head_changed=0 # Git branch can change without a `git ...`-prefixed command (aliases like `gco`, # tools like `gh pr checkout`, etc.). Detect HEAD changes and force a refresh. @@ -363,6 +416,7 @@ _cmux_precmd() { head_signature="$(_cmux_git_head_signature "$_CMUX_GIT_HEAD_PATH" 2>/dev/null || true)" if [[ -n "$head_signature" && "$head_signature" != "$_CMUX_GIT_HEAD_SIGNATURE" ]]; then _CMUX_GIT_HEAD_SIGNATURE="$head_signature" + git_head_changed=1 # Treat HEAD file change like a git command — force-replace any # running probe so the sidebar picks up the new branch immediately. _CMUX_GIT_FORCE=1 @@ -413,6 +467,12 @@ _cmux_precmd() { # alive while the shell is idle so gh-created PRs and merge status changes # appear even without another prompt. local should_restart_pr_poll=0 + local pr_context_changed=0 + if [[ -n "$_CMUX_PR_POLL_PWD" && "$pwd" != "$_CMUX_PR_POLL_PWD" ]]; then + pr_context_changed=1 + elif (( git_head_changed )); then + pr_context_changed=1 + fi if [[ "$pwd" != "$_CMUX_PR_POLL_PWD" ]]; then should_restart_pr_poll=1 elif (( _CMUX_PR_FORCE )); then @@ -423,6 +483,9 @@ _cmux_precmd() { if (( should_restart_pr_poll )); then _CMUX_PR_FORCE=0 + if (( pr_context_changed )); then + _cmux_clear_pr_for_panel + fi _cmux_start_pr_poll_loop "$pwd" 1 fi diff --git a/tests/test_issue_1138_sidebar_pr_polling.py b/tests/test_issue_1138_sidebar_pr_polling.py index 78cd36e5..92f48b24 100644 --- a/tests/test_issue_1138_sidebar_pr_polling.py +++ b/tests/test_issue_1138_sidebar_pr_polling.py @@ -1,8 +1,13 @@ #!/usr/bin/env python3 """ -Regression for issue #1138: -sidebar PR badges should recover from a transient gh failure without waiting -for another prompt, and a failed gh probe should not eagerly clear PR state. +Regression coverage for issue #1138. + +Validates that shell integration: +1) keeps polling PR state while idle and recovers after a transient gh failure +2) resolves the current branch PR via `gh pr view` instead of repository-wide + branch-name matching +3) clears stale PR state when the branch changes and the new probe fails +4) recovers when a gh probe wedges longer than the async timeout """ from __future__ import annotations @@ -45,13 +50,28 @@ def _git_stub() -> str: return textwrap.dedent( """\ #!/bin/sh + repo_path="$PWD" if [ "$1" = "-C" ]; then + repo_path="$2" shift shift fi + head_file="$repo_path/.git/HEAD" + branch="" + if [ -f "$head_file" ]; then + head_line="$(cat "$head_file")" + case "$head_line" in + ref:\ refs/heads/*) + branch="${head_line#ref: refs/heads/}" + ;; + esac + fi + if [ "$1" = "branch" ] && [ "$2" = "--show-current" ]; then - printf '%s\\n' 'feature/issue-1138' + if [ -n "$branch" ]; then + printf '%s\\n' "$branch" + fi exit 0 fi @@ -69,7 +89,13 @@ def _gh_stub() -> str: return textwrap.dedent( """\ #!/bin/sh + args_log="${CMUX_TEST_GH_ARGS_LOG:?}" count_file="${CMUX_TEST_GH_COUNT_FILE:?}" + scenario="${CMUX_TEST_SCENARIO:?}" + head_file="${CMUX_TEST_HEAD_FILE:?}" + + printf '%s\\n' "$*" >> "$args_log" + count=0 if [ -f "$count_file" ]; then count="$(cat "$count_file")" @@ -77,57 +103,138 @@ def _gh_stub() -> str: count=$((count + 1)) printf '%s\\n' "$count" > "$count_file" - if [ "$count" -eq 1 ]; then - printf 'rate limit exceeded\\n' >&2 - exit 1 + if [ "$1" != "pr" ] || [ "$2" != "view" ]; then + printf 'unexpected gh args: %s\\n' "$*" >&2 + exit 9 fi - printf '1138\\tOPEN\\thttps://github.com/manaflow-ai/cmux/pull/1138\\n' + branch="" + if [ -f "$head_file" ]; then + head_line="$(cat "$head_file")" + case "$head_line" in + ref:\ refs/heads/*) + branch="${head_line#ref: refs/heads/}" + ;; + esac + fi + + case "$scenario" in + transient_same_context) + if [ "$count" -eq 1 ]; then + printf 'rate limit exceeded\\n' >&2 + exit 1 + fi + printf '1138\\tOPEN\\thttps://github.com/manaflow-ai/cmux/pull/1138\\n' + ;; + branch_switch_clear) + if [ "$branch" = "feature/old" ]; then + printf '111\\tOPEN\\thttps://github.com/manaflow-ai/cmux/pull/111\\n' + exit 0 + fi + if [ "$branch" = "feature/new" ]; then + printf 'network unavailable\\n' >&2 + exit 1 + fi + printf 'no pull requests found for branch "%s"\\n' "$branch" >&2 + exit 1 + ;; + timeout_recovery) + if [ "$count" -eq 1 ]; then + sleep "${CMUX_TEST_HANG_SECONDS:-4}" + exit 0 + fi + printf '1138\\tOPEN\\thttps://github.com/manaflow-ai/cmux/pull/1138\\n' + ;; + *) + printf 'unknown scenario: %s\\n' "$scenario" >&2 + exit 2 + ;; + esac """ ) -def _shell_command(kind: str) -> str: +def _shell_command(kind: str, scenario: str) -> str: + shared = { + "transient_same_context": ( + 'cd "$CMUX_TEST_REPO"\n' + '_CMUX_PR_POLL_INTERVAL=1\n' + '_cmux_prompt_entry\n' + 'sleep 3\n' + '_cmux_cleanup\n' + ), + "branch_switch_clear": ( + 'cd "$CMUX_TEST_REPO"\n' + '_CMUX_PR_POLL_INTERVAL=10\n' + '_cmux_prompt_entry\n' + 'sleep 1\n' + 'printf \'ref: refs/heads/feature/new\\n\' > "$CMUX_TEST_HEAD_FILE"\n' + '_cmux_prompt_entry\n' + 'sleep 2\n' + '_cmux_cleanup\n' + ), + "timeout_recovery": ( + 'cd "$CMUX_TEST_REPO"\n' + '_CMUX_PR_POLL_INTERVAL=1\n' + '_CMUX_ASYNC_JOB_TIMEOUT=1\n' + '_cmux_prompt_entry\n' + 'sleep 4\n' + '_cmux_cleanup\n' + ), + }[scenario] + if kind == "zsh": return textwrap.dedent( - """\ + f"""\ source "$CMUX_TEST_SCRIPT" - _cmux_send() { print -r -- "$1" >> "$CMUX_TEST_SEND_LOG"; } - cd "$CMUX_TEST_REPO" - _CMUX_PR_POLL_INTERVAL=1 - _cmux_precmd - sleep 3 - _cmux_zshexit - """ + _cmux_send() {{ print -r -- "$1" >> "$CMUX_TEST_SEND_LOG"; }} + _cmux_prompt_entry() {{ _cmux_precmd; }} + _cmux_cleanup() {{ _cmux_zshexit; }} + {shared}""" ) if kind == "bash": return textwrap.dedent( - """\ + f"""\ source "$CMUX_TEST_SCRIPT" - _cmux_send() { printf '%s\\n' "$1" >> "$CMUX_TEST_SEND_LOG"; } - cd "$CMUX_TEST_REPO" - _CMUX_PR_POLL_INTERVAL=1 - _cmux_prompt_command - sleep 3 - type _cmux_bash_cleanup >/dev/null 2>&1 && _cmux_bash_cleanup - """ + trap - DEBUG + _cmux_send() {{ printf '%s\\n' "$1" >> "$CMUX_TEST_SEND_LOG"; }} + _cmux_prompt_entry() {{ _cmux_prompt_command; }} + _cmux_cleanup() {{ type _cmux_bash_cleanup >/dev/null 2>&1 && _cmux_bash_cleanup; }} + {shared}""" ) raise ValueError(f"Unsupported shell kind: {kind}") -def _run_case(base: Path, *, shell: str, shell_args: list[str], script: Path) -> tuple[int, str]: +def _read_lines(path: Path) -> list[str]: + if not path.exists(): + return [] + return [line.strip() for line in path.read_text(encoding="utf-8").splitlines() if line.strip()] + + +def _report_line(number: int) -> str: + return ( + f"report_pr {number} https://github.com/manaflow-ai/cmux/pull/{number} " + "--state=open --tab=00000000-0000-0000-0000-000000000001 " + "--panel=00000000-0000-0000-0000-000000000002" + ) + + +def _run_case(base: Path, *, shell: str, shell_args: list[str], script: Path, scenario: str) -> tuple[int, str]: bindir = base / "bin" repo = base / "repo" repo_git = repo / ".git" socket_path = base / "cmux.sock" - send_log = base / f"{shell}-send.log" - gh_count_file = base / f"{shell}-gh-count.txt" + send_log = base / f"{shell}-{scenario}-send.log" + gh_count_file = base / f"{shell}-{scenario}-gh-count.txt" + gh_args_log = base / f"{shell}-{scenario}-gh-args.log" + head_file = repo_git / "HEAD" bindir.mkdir(parents=True, exist_ok=True) repo_git.mkdir(parents=True, exist_ok=True) - (repo_git / "HEAD").write_text("ref: refs/heads/feature/issue-1138\n", encoding="utf-8") + initial_branch = "feature/old" if scenario == "branch_switch_clear" else "feature/issue-1138" + head_file.write_text(f"ref: refs/heads/{initial_branch}\n", encoding="utf-8") _write_executable(bindir / "git", _git_stub()) _write_executable(bindir / "gh", _gh_stub()) @@ -140,41 +247,65 @@ def _run_case(base: Path, *, shell: str, shell_args: list[str], script: Path) -> env["CMUX_TEST_REPO"] = str(repo) env["CMUX_TEST_SEND_LOG"] = str(send_log) env["CMUX_TEST_GH_COUNT_FILE"] = str(gh_count_file) + env["CMUX_TEST_GH_ARGS_LOG"] = str(gh_args_log) + env["CMUX_TEST_SCENARIO"] = scenario + env["CMUX_TEST_HEAD_FILE"] = str(head_file) + env["CMUX_TEST_HANG_SECONDS"] = "4" with BoundUnixSocket(socket_path): result = subprocess.run( - [shell, *shell_args, _shell_command(shell)], + [shell, *shell_args, _shell_command(shell, scenario)], env=env, capture_output=True, text=True, - timeout=10, + timeout=12, ) - output = (result.stdout or "") + (result.stderr or "") + combined_output = (result.stdout or "") + (result.stderr or "") if result.returncode != 0: - return (result.returncode, output) + return (result.returncode, combined_output) - send_lines = [] - if send_log.exists(): - send_lines = [line.strip() for line in send_log.read_text(encoding="utf-8").splitlines() if line.strip()] + send_lines = _read_lines(send_log) + gh_args_lines = _read_lines(gh_args_log) + gh_count = int((gh_count_file.read_text(encoding="utf-8").strip() or "0")) if gh_count_file.exists() else 0 - gh_count = 0 - if gh_count_file.exists(): - gh_count = int(gh_count_file.read_text(encoding="utf-8").strip() or "0") + if not gh_args_lines: + return (1, f"{shell}/{scenario}: expected at least one gh invocation") + if any(not line.startswith("pr view ") for line in gh_args_lines): + return (1, f"{shell}/{scenario}: expected gh pr view only\n" + "\n".join(gh_args_lines)) - report_line = ( - "report_pr 1138 https://github.com/manaflow-ai/cmux/pull/1138 " - "--state=open --tab=00000000-0000-0000-0000-000000000001 " - "--panel=00000000-0000-0000-0000-000000000002" - ) - if gh_count < 2: - return (1, f"{shell}: expected at least 2 gh probes while idle, saw {gh_count}") - if any(line.startswith("clear_pr ") for line in send_lines): - return (1, f"{shell}: transient gh failure should not send clear_pr\n" + "\n".join(send_lines)) - if report_line not in send_lines: - return (1, f"{shell}: expected recovered report_pr payload\n" + "\n".join(send_lines)) + if scenario == "transient_same_context": + if gh_count < 2: + return (1, f"{shell}/{scenario}: expected at least 2 gh probes while idle, saw {gh_count}") + if any(line.startswith("clear_pr ") for line in send_lines): + return (1, f"{shell}/{scenario}: transient failure should not clear PR state\n" + "\n".join(send_lines)) + if _report_line(1138) not in send_lines: + return (1, f"{shell}/{scenario}: expected recovered report_pr payload\n" + "\n".join(send_lines)) + return (0, f"{shell}/{scenario}: ok") - return (0, f"{shell}: ok") + if scenario == "branch_switch_clear": + old_report = _report_line(111) + if old_report not in send_lines: + return (1, f"{shell}/{scenario}: missing old-branch report\n" + "\n".join(send_lines)) + try: + old_index = send_lines.index(old_report) + except ValueError: + return (1, f"{shell}/{scenario}: missing old-branch report\n" + "\n".join(send_lines)) + clear_indices = [idx for idx, line in enumerate(send_lines) if line.startswith("clear_pr ")] + if not clear_indices: + return (1, f"{shell}/{scenario}: expected clear_pr after branch change\n" + "\n".join(send_lines)) + if clear_indices[0] <= old_index: + return (1, f"{shell}/{scenario}: clear_pr happened before old report\n" + "\n".join(send_lines)) + return (0, f"{shell}/{scenario}: ok") + + if scenario == "timeout_recovery": + if gh_count < 2: + return (1, f"{shell}/{scenario}: expected timed-out probe to be retried, saw {gh_count}") + if _report_line(1138) not in send_lines: + return (1, f"{shell}/{scenario}: missing report_pr after timeout recovery\n" + "\n".join(send_lines)) + return (0, f"{shell}/{scenario}: ok") + + return (1, f"{shell}/{scenario}: unhandled scenario") def main() -> int: @@ -183,6 +314,11 @@ def main() -> int: ("zsh", ["-f", "-c"], root / "Resources" / "shell-integration" / "cmux-zsh-integration.zsh"), ("bash", ["--noprofile", "--norc", "-c"], root / "Resources" / "shell-integration" / "cmux-bash-integration.bash"), ] + scenarios = [ + "transient_same_context", + "branch_switch_clear", + "timeout_recovery", + ] base = Path("/tmp") / f"cmux_issue_1138_pr_poll_{os.getpid()}" try: @@ -194,9 +330,16 @@ def main() -> int: if not script.exists(): print(f"SKIP: missing integration script at {script}") continue - rc, detail = _run_case(base / shell, shell=shell, shell_args=shell_args, script=script) - if rc != 0: - failures.append(detail) + for scenario in scenarios: + rc, detail = _run_case( + base / f"{shell}-{scenario}", + shell=shell, + shell_args=shell_args, + script=script, + scenario=scenario, + ) + if rc != 0: + failures.append(detail) if failures: print("FAIL:") @@ -204,7 +347,7 @@ def main() -> int: print(failure) return 1 - print("PASS: shell integrations keep polling PR state and recover after transient gh failures") + print("PASS: shell integrations poll PR state robustly across transient failures, branch changes, and timeouts") return 0 finally: shutil.rmtree(base, ignore_errors=True)