Skip to content

Commit 38439a8

Browse files
hiroshinishioclaude
andcommitted
Add test for push webhook event handling
🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
1 parent b7bb29a commit 38439a8

File tree

10 files changed

+827
-6
lines changed

10 files changed

+827
-6
lines changed
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import requests
2+
from config import GITHUB_API_URL, GITHUB_APP_USER_NAME, PER_PAGE, TIMEOUT
3+
from services.github.utils.create_headers import create_headers
4+
from utils.error.handle_exceptions import handle_exceptions
5+
6+
7+
@handle_exceptions(default_return_value=[], raise_on_error=False)
8+
def get_open_pull_requests(owner: str, repo: str, target_branch: str, token: str):
9+
"""https://docs.github.com/en/rest/pulls/pulls?apiVersion=2022-11-28#list-pull-requests"""
10+
url = f"{GITHUB_API_URL}/repos/{owner}/{repo}/pulls"
11+
headers = create_headers(token=token)
12+
pull_requests: list[dict] = []
13+
page = 1
14+
15+
while True:
16+
params = {
17+
"base": target_branch,
18+
"state": "open",
19+
"per_page": PER_PAGE,
20+
"page": page,
21+
}
22+
response = requests.get(
23+
url=url, headers=headers, params=params, timeout=TIMEOUT
24+
)
25+
response.raise_for_status()
26+
prs = response.json()
27+
28+
if not prs:
29+
break
30+
31+
gitauto_prs = [
32+
pr
33+
for pr in prs
34+
if pr.get("user", {}).get("login", "").lower()
35+
== GITHUB_APP_USER_NAME.lower()
36+
]
37+
pull_requests.extend(gitauto_prs)
38+
page += 1
39+
40+
return pull_requests
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
from unittest.mock import Mock, patch
2+
from services.github.pulls.get_open_pull_requests import get_open_pull_requests
3+
4+
5+
@patch(
6+
"services.github.pulls.get_open_pull_requests.GITHUB_APP_USER_NAME",
7+
"gitauto-ai[bot]",
8+
)
9+
def test_get_open_pull_requests_success():
10+
mock_response = Mock()
11+
mock_response.json.return_value = [
12+
{
13+
"number": 123,
14+
"title": "Test PR 1",
15+
"head": {"ref": "feature-1"},
16+
"base": {"ref": "main"},
17+
"user": {"login": "gitauto-ai[bot]"},
18+
},
19+
{
20+
"number": 124,
21+
"title": "Test PR 2",
22+
"head": {"ref": "feature-2"},
23+
"base": {"ref": "main"},
24+
"user": {"login": "gitauto-ai[bot]"},
25+
},
26+
]
27+
mock_response.raise_for_status = Mock()
28+
29+
with patch("requests.get") as mock_get:
30+
mock_get.side_effect = [
31+
mock_response,
32+
Mock(json=lambda: [], raise_for_status=Mock()),
33+
]
34+
35+
result = get_open_pull_requests(
36+
owner="test-owner",
37+
repo="test-repo",
38+
target_branch="main",
39+
token="test-token",
40+
)
41+
42+
assert len(result) == 2
43+
assert result[0]["number"] == 123
44+
assert result[1]["number"] == 124
45+
assert mock_get.call_count == 2
46+
47+
48+
@patch(
49+
"services.github.pulls.get_open_pull_requests.GITHUB_APP_USER_NAME",
50+
"gitauto-ai[bot]",
51+
)
52+
def test_get_open_pull_requests_pagination():
53+
first_page = [
54+
{"number": i, "user": {"login": "gitauto-ai[bot]"}} for i in range(100)
55+
]
56+
second_page = [
57+
{"number": i, "user": {"login": "gitauto-ai[bot]"}} for i in range(100, 150)
58+
]
59+
60+
mock_response_1 = Mock()
61+
mock_response_1.json.return_value = first_page
62+
mock_response_1.raise_for_status = Mock()
63+
64+
mock_response_2 = Mock()
65+
mock_response_2.json.return_value = second_page
66+
mock_response_2.raise_for_status = Mock()
67+
68+
mock_response_3 = Mock()
69+
mock_response_3.json.return_value = []
70+
mock_response_3.raise_for_status = Mock()
71+
72+
with patch("requests.get") as mock_get:
73+
mock_get.side_effect = [mock_response_1, mock_response_2, mock_response_3]
74+
75+
result = get_open_pull_requests(
76+
owner="test-owner",
77+
repo="test-repo",
78+
target_branch="develop",
79+
token="test-token",
80+
)
81+
82+
assert len(result) == 150
83+
assert mock_get.call_count == 3
84+
85+
86+
def test_get_open_pull_requests_empty():
87+
mock_response = Mock()
88+
mock_response.json.return_value = []
89+
mock_response.raise_for_status = Mock()
90+
91+
with patch("requests.get", return_value=mock_response):
92+
result = get_open_pull_requests(
93+
owner="test-owner",
94+
repo="test-repo",
95+
target_branch="main",
96+
token="test-token",
97+
)
98+
99+
assert not result
100+
101+
102+
def test_get_open_pull_requests_error():
103+
with patch("requests.get", side_effect=Exception("API error")):
104+
result = get_open_pull_requests(
105+
owner="test-owner",
106+
repo="test-repo",
107+
target_branch="main",
108+
token="test-token",
109+
)
110+
111+
assert not result
112+
113+
114+
@patch(
115+
"services.github.pulls.get_open_pull_requests.GITHUB_APP_USER_NAME",
116+
"gitauto-ai[bot]",
117+
)
118+
def test_get_open_pull_requests_filters_non_gitauto_prs():
119+
mock_response = Mock()
120+
mock_response.json.return_value = [
121+
{
122+
"number": 123,
123+
"title": "GitAuto PR",
124+
"head": {"ref": "feature-1"},
125+
"base": {"ref": "main"},
126+
"user": {"login": "gitauto-ai[bot]"},
127+
},
128+
{
129+
"number": 124,
130+
"title": "Developer PR",
131+
"head": {"ref": "feature-2"},
132+
"base": {"ref": "main"},
133+
"user": {"login": "developer123"},
134+
},
135+
{
136+
"number": 125,
137+
"title": "Another Bot PR",
138+
"head": {"ref": "feature-3"},
139+
"base": {"ref": "main"},
140+
"user": {"login": "renovate[bot]"},
141+
},
142+
]
143+
mock_response.raise_for_status = Mock()
144+
145+
with patch("requests.get") as mock_get:
146+
mock_get.side_effect = [
147+
mock_response,
148+
Mock(json=lambda: [], raise_for_status=Mock()),
149+
]
150+
151+
result = get_open_pull_requests(
152+
owner="test-owner",
153+
repo="test-repo",
154+
target_branch="main",
155+
token="test-token",
156+
)
157+
158+
assert len(result) == 1
159+
assert result[0]["number"] == 123
160+
assert result[0]["user"]["login"] == "gitauto-ai[bot]"
161+
162+
163+
@patch(
164+
"services.github.pulls.get_open_pull_requests.GITHUB_APP_USER_NAME",
165+
"gitauto-ai[bot]",
166+
)
167+
def test_get_open_pull_requests_no_gitauto_prs():
168+
mock_response = Mock()
169+
mock_response.json.return_value = [
170+
{
171+
"number": 124,
172+
"title": "Developer PR",
173+
"head": {"ref": "feature-2"},
174+
"base": {"ref": "main"},
175+
"user": {"login": "developer123"},
176+
},
177+
{
178+
"number": 125,
179+
"title": "Another Bot PR",
180+
"head": {"ref": "feature-3"},
181+
"base": {"ref": "main"},
182+
"user": {"login": "renovate[bot]"},
183+
},
184+
]
185+
mock_response.raise_for_status = Mock()
186+
187+
with patch("requests.get") as mock_get:
188+
mock_get.side_effect = [
189+
mock_response,
190+
Mock(json=lambda: [], raise_for_status=Mock()),
191+
]
192+
193+
result = get_open_pull_requests(
194+
owner="test-owner",
195+
repo="test-repo",
196+
target_branch="main",
197+
token="test-token",
198+
)
199+
200+
assert not result
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
from unittest.mock import Mock, patch
2+
from services.github.pulls.update_pull_request_branch import update_pull_request_branch
3+
4+
5+
def test_update_pull_request_branch_success():
6+
mock_response = Mock()
7+
mock_response.status_code = 202
8+
9+
with patch("requests.put", return_value=mock_response) as mock_put:
10+
status, error = update_pull_request_branch(
11+
owner="test-owner", repo="test-repo", pull_number=123, token="test-token"
12+
)
13+
14+
assert status == "updated"
15+
assert error is None
16+
mock_put.assert_called_once()
17+
_, kwargs = mock_put.call_args
18+
assert "/repos/test-owner/test-repo/pulls/123/update-branch" in kwargs["url"]
19+
20+
21+
def test_update_pull_request_branch_error():
22+
with patch("requests.put", side_effect=Exception("API error")):
23+
status, error = update_pull_request_branch(
24+
owner="test-owner", repo="test-repo", pull_number=123, token="test-token"
25+
)
26+
27+
assert status == "failed"
28+
assert error == "Unknown error"
29+
30+
31+
def test_update_pull_request_branch_already_up_to_date():
32+
mock_response = Mock()
33+
mock_response.status_code = 422
34+
mock_response.json.return_value = {
35+
"message": "There are no new commits on the base branch."
36+
}
37+
38+
with patch("requests.put", return_value=mock_response):
39+
status, error = update_pull_request_branch(
40+
owner="test-owner", repo="test-repo", pull_number=123, token="test-token"
41+
)
42+
43+
assert status == "up_to_date"
44+
assert error is None
45+
46+
47+
def test_update_pull_request_branch_http_error():
48+
mock_response = Mock()
49+
mock_response.status_code = 422
50+
mock_response.json.return_value = {"message": "Merge conflict detected"}
51+
52+
with patch("requests.put", return_value=mock_response):
53+
status, error = update_pull_request_branch(
54+
owner="test-owner", repo="test-repo", pull_number=123, token="test-token"
55+
)
56+
57+
assert status == "failed"
58+
assert error is not None
59+
assert "422" in error
60+
assert "Merge conflict detected" in error
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
# pylint: disable=broad-exception-caught
2+
3+
import requests
4+
from config import GITHUB_API_URL, TIMEOUT
5+
from services.github.utils.create_headers import create_headers
6+
from utils.error.handle_exceptions import handle_exceptions
7+
8+
9+
@handle_exceptions(
10+
default_return_value=("failed", "Unknown error"), raise_on_error=False
11+
)
12+
def update_pull_request_branch(
13+
owner: str, repo: str, pull_number: int, token: str
14+
) -> tuple[str, str | None]:
15+
"""https://docs.github.com/en/rest/pulls/pulls?apiVersion=2022-11-28#update-a-pull-request-branch"""
16+
url = f"{GITHUB_API_URL}/repos/{owner}/{repo}/pulls/{pull_number}/update-branch"
17+
headers = create_headers(token=token)
18+
response = requests.put(url=url, headers=headers, timeout=TIMEOUT)
19+
20+
if response.status_code == 202:
21+
return ("updated", None)
22+
23+
if response.status_code == 422:
24+
try:
25+
error_detail = response.json().get("message", "")
26+
if "no new commits" in error_detail.lower():
27+
return ("up_to_date", None)
28+
except Exception:
29+
pass
30+
31+
if response.status_code not in [200, 202]:
32+
error_msg = f"HTTP {response.status_code}"
33+
try:
34+
error_detail = response.json().get("message", "")
35+
if error_detail:
36+
error_msg += f": {error_detail}"
37+
except Exception:
38+
pass
39+
return ("failed", error_msg)
40+
41+
return ("updated", None)
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
from typing import TypedDict
2+
from services.github.types.installation import Installation
3+
from services.github.types.repository import Repository
4+
from services.github.types.sender import Sender
5+
6+
7+
class PushWebhookPayload(TypedDict):
8+
ref: str
9+
repository: Repository
10+
sender: Sender
11+
installation: Installation

0 commit comments

Comments
 (0)