Skip to content
Merged
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
3 changes: 3 additions & 0 deletions .Jules/palette.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
## 2025-01-26 - [Silent Waits in CI]
**Learning:** Long silent waits in CLI tools (especially in CI/non-interactive mode) cause user anxiety about hung processes.
**Action:** Always provide periodic heartbeat logs (e.g. every 10s) for long operations in non-interactive environments.
Comment on lines +1 to +3
Copy link

Copilot AI Feb 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This introduces a new .Jules/ directory, but the repo already has a .jules/ directory. Having both differs only by case and can cause checkout/merge issues on case-insensitive filesystems (Windows/macOS). Consider placing this file under the existing .jules/ directory (or standardizing on one casing).

Copilot uses AI. Check for mistakes.
17 changes: 16 additions & 1 deletion main.py
Original file line number Diff line number Diff line change
Expand Up @@ -307,8 +307,23 @@ def _get_progress_bar_width() -> int:


def countdown_timer(seconds: int, message: str = "Waiting") -> None:
"""Shows a countdown timer if strictly in a TTY, otherwise just sleeps."""
"""Show a countdown in interactive/color mode; in no-color/non-interactive
mode, sleep silently for short waits and log periodic heartbeat messages
for longer waits."""
if not USE_COLORS:
# UX Improvement: For long waits in non-interactive/no-color mode (e.g. CI),
# log periodic updates instead of sleeping silently.
if seconds > 10:
step = 10
for remaining in range(seconds, 0, -step):
# Don't log the first one if we already logged "Waiting..." before calling this
if remaining < seconds:
log.info(f"{sanitize_for_log(message)}: {remaining}s remaining...")

sleep_time = min(step, remaining)
time.sleep(sleep_time)
return

time.sleep(seconds)
return

Expand Down
6 changes: 2 additions & 4 deletions tests/test_push_rules_perf.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,6 @@
# Add root to path to import main
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))

import main

class TestPushRulesPerf(unittest.TestCase):
def setUp(self):
# Ensure we are using the current main module instance (in case of reloads)
Expand Down Expand Up @@ -55,7 +53,7 @@ def test_push_rules_single_batch_optimization(self, mock_executor, mock_as_compl
# For this test, we mock _api_post_form?
# No, _api_post_form calls client.post.

main.push_rules(
self.main.push_rules(
self.profile_id,
self.folder_name,
self.folder_id,
Expand Down Expand Up @@ -89,7 +87,7 @@ def test_push_rules_multi_batch(self, mock_executor, mock_as_completed):

mock_as_completed.return_value = [mock_future, mock_future] # 2 batches

main.push_rules(
self.main.push_rules(
self.profile_id,
self.folder_name,
self.folder_id,
Expand Down
49 changes: 43 additions & 6 deletions tests/test_ux.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,17 +30,54 @@
# Check for ANSI clear line code
assert "\033[K" in combined_output

def test_countdown_timer_no_colors(monkeypatch):
"""Verify that countdown_timer sleeps without writing to stderr if NO_COLOR."""
def test_countdown_timer_no_colors_short(monkeypatch):
"""Verify that short countdowns sleep silently without writing to stderr if NO_COLOR."""
monkeypatch.setattr(main, "USE_COLORS", False)
mock_stderr = MagicMock()
monkeypatch.setattr(sys, "stderr", mock_stderr)
mock_sleep = MagicMock()
monkeypatch.setattr(main.time, "sleep", mock_sleep)

main.countdown_timer(3, "Test")
# Mock log to ensure it's not called
mock_log = MagicMock()
monkeypatch.setattr(main, "log", mock_log)

# Should not write to stderr
mock_stderr.write.assert_not_called()
main.countdown_timer(10, "Test")

# Should not log
mock_log.info.assert_not_called()
# Should call sleep exactly once with full seconds
mock_sleep.assert_called_once_with(3)
mock_sleep.assert_called_once_with(10)
# Should not write anything to stderr for short, no-color countdowns
mock_stderr.write.assert_not_called()
mock_stderr.flush.assert_not_called()


def test_countdown_timer_no_colors_long(monkeypatch):
"""Verify that long countdowns log periodic updates if NO_COLOR."""
monkeypatch.setattr(main, "USE_COLORS", False)
mock_sleep = MagicMock()
monkeypatch.setattr(main.time, "sleep", mock_sleep)

mock_log = MagicMock()
monkeypatch.setattr(main, "log", mock_log)

# Test with 25 seconds
main.countdown_timer(25, "LongWait")

# Expected sleep calls:
# 1. min(10, 25) -> 10 (remaining 25)
# 2. min(10, 15) -> 10 (remaining 15)
# 3. min(10, 5) -> 5 (remaining 5)

# Expected log calls:
# 1. "LongWait: 15s remaining..." (after first sleep/loop iteration)
# 2. "LongWait: 5s remaining..." (after second sleep/loop iteration)

assert mock_sleep.call_count == 3

Check notice

Code scanning / Bandit

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code. Note test

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code.
mock_sleep.assert_any_call(10)
mock_sleep.assert_any_call(5)

assert mock_log.info.call_count == 2

Check notice

Code scanning / Bandit

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code. Note test

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code.
mock_log.info.assert_any_call("LongWait: 15s remaining...")
mock_log.info.assert_any_call("LongWait: 5s remaining...")
Loading