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
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ python3 scripts/aws/filter_log_events_across_streams.py --hours 12 --owner Foxqu
## Coding Standards

- **No DOCSTRINGS**: Don't add unless told. Don't delete existing unless outdated.
- **COMMENTS**: Don't delete unless outdated. Preserve URLs. One line when possible.
- **COMMENTS**: Don't delete unless outdated. Preserve URLs. One line when possible. **Don't hard-wrap sentences mid-thought** — if a comment is two sentences, two lines is fine; but don't break one sentence across multiple lines because it makes the comment unreadable when scanned. Let the editor wrap it visually.
- **LOGGERS**: Every `continue`, `break`, `return` inside a function MUST have a preceding `logger.info(...)` (or warning/error). Also log at every conditional branch to show which path was taken.
- **`set_xxx` EARLIEST**: Call `set_trigger`, `set_owner_repo`, `set_pr_number` etc. at the earliest point in each handler, right after the value is known.
- **Don't repeat structured log context**: `set_owner_repo` in `main.py` already adds `owner_repo` to every log entry. Don't repeat owner/repo in individual logger messages.
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "GitAuto"
version = "1.27.0"
version = "1.33.0"
requires-python = ">=3.14"
dependencies = [
"annotated-doc==0.0.4",
Expand Down
4 changes: 3 additions & 1 deletion scripts/lint/check_cast_usage.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@
# Detect `cast` usage from typing module in staged Python implementation files.
# Rule: No cast in impl files — fix underlying types or use isinstance narrowing.
# Allowed in test files (test_*.py, conftest.py) for TypedDict fixtures.
# Also allowed in utils/error/handle_exceptions.py: the generic decorator pattern with ParamSpec + TypeVar genuinely can't round-trip types without cast (async wrapper's Coroutine[...R] vs declared Callable[P, R]; helper returns typed Any).
set -uo pipefail

STAGED_PY_FILES=$(git diff --cached --name-only --diff-filter=d -- '*.py' \
| grep -v '^\.\?venv/' \
| grep -v '^schemas/' \
| grep -vE '(^|/)test_' \
| grep -vE '(^|/)conftest\.py$')
| grep -vE '(^|/)conftest\.py$' \
| grep -v '^utils/error/handle_exceptions\.py$')

if [ -z "$STAGED_PY_FILES" ]; then
exit 0
Expand Down
8 changes: 5 additions & 3 deletions services/aws/test_delete_scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -204,15 +204,17 @@ def test_delete_scheduler_with_various_schedule_names(


def test_delete_scheduler_logging_level(mock_scheduler_client):
"""Test that the function uses logger.info for success messages."""
"""Test that the function uses logger.info for success messages. Patch the
full module-level logger (not logger.info directly) so handle_exceptions'
shared Logger.info calls hit the real logger, not this test's mock."""
schedule_name = "test-schedule"
mock_scheduler_client.delete_schedule.return_value = None

with patch("services.aws.delete_scheduler.logger.info") as mock_info:
with patch("services.aws.delete_scheduler.logger") as mock_logger:
result = delete_scheduler(schedule_name)

assert result is True
mock_info.assert_called_once_with(
mock_logger.info.assert_called_once_with(
"Deleted EventBridge Scheduler: %s", schedule_name
)

Expand Down
2 changes: 2 additions & 0 deletions services/git/git_commit_and_push.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ def git_commit_and_push(
run_subprocess(["git", "add"] + files, clone_dir)

if skip_ci:
logger.info("git_commit_and_push: appending [skip ci] to message")
message = f"{message} [skip ci]"
message = format_commit_message(message=message, base_args=base_args)

Expand All @@ -31,6 +32,7 @@ def git_commit_and_push(
run_subprocess(["git", "remote", "set-url", "origin", clone_url], clone_dir)
push_cmd = ["git", "push"]
if force:
logger.info("git_commit_and_push: force push via --force-with-lease")
push_cmd.append("--force-with-lease")
push_cmd.extend(["origin", f"HEAD:refs/heads/{new_branch}"])
run_subprocess(push_cmd, clone_dir)
Expand Down
126 changes: 116 additions & 10 deletions services/git/test_git_commit_and_push.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

from services.git.git_clone_to_tmp import git_clone_to_tmp
from services.git.git_commit_and_push import git_commit_and_push
from utils.error.handle_exceptions import TRANSIENT_MAX_ATTEMPTS


def test_git_commit_and_push_success(create_test_base_args):
Expand Down Expand Up @@ -89,8 +90,11 @@ def mock_run(args, cwd):
files=["file.py"],
)
assert result is True
# -m is at index 2, message is at index 3
assert "[skip ci]" in commit_args_captured[3]
# commit_args_captured[3] is the full message: "<subject> [skip ci]"
# followed by a "\n\nCo-Authored-By: ..." trailer appended by
# format_commit_message. Assert the subject line (first line) exactly.
assert commit_args_captured[:3] == ["git", "commit", "-m"]
assert commit_args_captured[3].splitlines()[0] == "Update file.py [skip ci]"


def test_git_commit_and_push_stages_specific_files(create_test_base_args):
Expand All @@ -109,8 +113,7 @@ def mock_run(args, cwd):
files=["old.py", "new.py"],
)
assert result is True
assert "old.py" in add_args_captured
assert "new.py" in add_args_captured
assert add_args_captured == ["git", "add", "old.py", "new.py"]


def test_git_commit_and_push_force_push(create_test_base_args):
Expand All @@ -122,15 +125,26 @@ def mock_run(args, cwd):
push_args_captured = args
return MagicMock(returncode=0, stdout="")

base_args = create_test_base_args(
clone_url="https://x-access-token:tok@github.com/o/r.git",
new_branch="feature/force-push",
)

with patch("services.git.git_commit_and_push.run_subprocess", side_effect=mock_run):
result = git_commit_and_push(
base_args=create_test_base_args(),
base_args=base_args,
message="Rebase onto release/20260422",
files=["app.py"],
force=True,
)
assert result is True
assert "--force-with-lease" in push_args_captured
assert push_args_captured == [
"git",
"push",
"--force-with-lease",
"origin",
"HEAD:refs/heads/feature/force-push",
]


def test_git_commit_and_push_no_force_by_default(create_test_base_args):
Expand All @@ -142,14 +156,104 @@ def mock_run(args, cwd):
push_args_captured = args
return MagicMock(returncode=0, stdout="")

base_args = create_test_base_args(
clone_url="https://x-access-token:tok@github.com/o/r.git",
new_branch="feature/normal-push",
)

with patch("services.git.git_commit_and_push.run_subprocess", side_effect=mock_run):
result = git_commit_and_push(
base_args=create_test_base_args(),
base_args=base_args,
message="Normal push",
files=["app.py"],
)
assert result is True
assert "--force-with-lease" not in push_args_captured
assert push_args_captured == [
"git",
"push",
"origin",
"HEAD:refs/heads/feature/normal-push",
]


def test_git_commit_and_push_retries_on_github_500(create_test_base_args):
"""Sentry AGENT-36Z/36J: GitHub returns transient 500 on push. Retry and succeed."""
attempts = {"push": 0}

def mock_run(args, cwd):
if args[:2] == ["git", "push"]:
attempts["push"] += 1
if attempts["push"] == 1:
raise ValueError(
"Command failed: remote: Internal Server Error\n"
"To https://github.com/org/repo.git\n"
" ! [remote rejected] HEAD -> branch (Internal Server Error)"
)
return MagicMock(returncode=0, stdout="")

with (
patch("services.git.git_commit_and_push.run_subprocess", side_effect=mock_run),
patch("utils.error.handle_exceptions.time.sleep"),
):
result = git_commit_and_push(
base_args=create_test_base_args(),
message="Update file.py",
files=["file.py"],
)
assert result is True
assert attempts["push"] == 2


def test_git_commit_and_push_gives_up_after_max_github_500s(create_test_base_args):
"""If GitHub keeps 500-ing past the retry budget, surface the failure."""
attempts = {"push": 0}

def mock_run(args, cwd):
if args[:2] == ["git", "push"]:
attempts["push"] += 1
raise ValueError(
"Command failed: remote: Internal Server Error\n"
" ! [remote rejected] HEAD -> branch (Internal Server Error)"
)
return MagicMock(returncode=0, stdout="")

with (
patch("services.git.git_commit_and_push.run_subprocess", side_effect=mock_run),
patch("utils.error.handle_exceptions.time.sleep"),
):
result = git_commit_and_push(
base_args=create_test_base_args(),
message="Update file.py",
files=["file.py"],
)
assert result is False
assert attempts["push"] == TRANSIENT_MAX_ATTEMPTS


def test_git_commit_and_push_does_not_retry_non_transient(create_test_base_args):
"""A 'pathspec did not match' or similar non-transient failure must NOT retry —
otherwise we'd waste time on errors that will never succeed."""
attempts = {"push": 0}

def mock_run(args, cwd):
if args[:2] == ["git", "push"]:
attempts["push"] += 1
raise ValueError(
"Command failed: error: src refspec main does not match any"
)
return MagicMock(returncode=0, stdout="")

with (
patch("services.git.git_commit_and_push.run_subprocess", side_effect=mock_run),
patch("utils.error.handle_exceptions.time.sleep"),
):
result = git_commit_and_push(
base_args=create_test_base_args(),
message="Update file.py",
files=["file.py"],
)
assert result is False
assert attempts["push"] == 1


@pytest.mark.integration
Expand Down Expand Up @@ -179,10 +283,12 @@ def test_git_commit_and_push_to_local_bare(local_repo, create_test_base_args):

bare_dir = bare_url.replace("file://", "")
log = subprocess.run(
["git", "log", "--oneline", "feature/sociable-push", "-1"],
["git", "log", "--format=%s", "feature/sociable-push", "-1"],
cwd=bare_dir,
capture_output=True,
text=True,
check=False,
)
assert "Add new file" in log.stdout
# Commit message has a trailer appended by format_commit_message; assert
# the subject line (first line) matches exactly.
assert log.stdout.strip().splitlines()[0] == "Add new file"
50 changes: 39 additions & 11 deletions services/github/files/test_get_remote_file_content_by_url.py
Original file line number Diff line number Diff line change
Expand Up @@ -450,7 +450,16 @@ def test_different_branch_and_commit_sha(
file_path, content = get_remote_file_content_by_url(url, "test-token")

assert file_path == "src/test.py"
assert "```src/test.py" in content
assert content == (
"```src/test.py\n"
"1: def hello_world():\n"
"2: print('Hello, World!')\n"
"3: return 'success'\n"
"4: \n"
"5: if __name__ == '__main__':\n"
"6: hello_world()\n"
"```"
)
mock_requests_get.assert_called_once_with(
url="https://api.github.com/repos/test-owner/test-repo/contents/src/test.py?ref=feature-branch",
headers={"Authorization": "Bearer test-token"},
Expand Down Expand Up @@ -483,7 +492,16 @@ def test_nested_file_path(
file_path, content = get_remote_file_content_by_url(url, "test-token")

assert file_path == "src/utils/helpers/file.py"
assert "```src/utils/helpers/file.py" in content
assert content == (
"```src/utils/helpers/file.py\n"
"1: def hello_world():\n"
"2: print('Hello, World!')\n"
"3: return 'success'\n"
"4: \n"
"5: if __name__ == '__main__':\n"
"6: hello_world()\n"
"```"
)
mock_requests_get.assert_called_once_with(
url="https://api.github.com/repos/test-owner/test-repo/contents/src/utils/helpers/file.py?ref=main",
headers={"Authorization": "Bearer test-token"},
Expand Down Expand Up @@ -629,7 +647,17 @@ def test_various_parameter_combinations(
result_file_path, content = get_remote_file_content_by_url(url, "test-token")

assert result_file_path == file_path
assert f"```{file_path}" in content
# Exact content: fenced block with filename header and numbered sample.
assert content == (
f"```{file_path}\n"
"1: def hello_world():\n"
"2: print('Hello, World!')\n"
"3: return 'success'\n"
"4: \n"
"5: if __name__ == '__main__':\n"
"6: hello_world()\n"
"```"
)
expected_url = f"https://api.github.com/repos/{owner}/{repo}/contents/{file_path}?ref={ref}"
mock_requests_get.assert_called_once_with(
url=expected_url,
Expand Down Expand Up @@ -670,11 +698,13 @@ def test_large_file_content(
file_path, content = get_remote_file_content_by_url(url, "test-token")

assert file_path == "src/test.py"
assert "```src/test.py" in content
assert "1: # Large file" in content
assert "101: print('line')" in content
# Should have 102 lines total (1 header + 100 print lines + 1 empty line at end)
assert len(content.split("\n")) >= 104 # Including the header lines
# Build the exact expected content: "# Large file" + 100 "print('line')"
# rows + one trailing empty line (trailing newline in `large_content`).
numbered = ["1: # Large file"]
numbered.extend(f"{i + 2}: print('line')" for i in range(100))
numbered.append("102: ") # trailing empty line from large_content's final \n
expected_body = "\n".join(numbered)
assert content == f"```src/test.py\n{expected_body}\n```"

@patch("services.github.files.get_remote_file_content_by_url.requests.get")
@patch("services.github.files.get_remote_file_content_by_url.create_headers")
Expand All @@ -694,9 +724,7 @@ def test_404_returns_empty_without_sentry(
mock_response.status_code = 404
mock_requests_get.return_value = mock_response

with patch(
"utils.error.handle_exceptions.sentry_sdk.capture_exception"
) as mock_sentry:
with patch("sentry_sdk.capture_exception") as mock_sentry:
url = "https://github.com/test-owner/test-repo/blob/main/missing.py"
result = get_remote_file_content_by_url(url, "test-token")

Expand Down
Loading