recce-nightly 1.15.0.20250806__py3-none-any.whl → 1.26.0.20251124__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of recce-nightly might be problematic. Click here for more details.

Files changed (167) hide show
  1. recce/VERSION +1 -1
  2. recce/__init__.py +5 -0
  3. recce/adapter/dbt_adapter/__init__.py +12 -3
  4. recce/artifact.py +74 -1
  5. recce/cli.py +642 -101
  6. recce/config.py +2 -2
  7. recce/connect_to_cloud.py +1 -1
  8. recce/core.py +2 -2
  9. recce/data/404.html +1 -1
  10. recce/data/__next.__PAGE__.txt +10 -0
  11. recce/data/__next._full.txt +23 -0
  12. recce/data/__next._head.txt +8 -0
  13. recce/data/__next._index.txt +8 -0
  14. recce/data/__next._tree.txt +5 -0
  15. recce/data/_next/static/52aV_JrNUZU6dMFgvTQEO/_buildManifest.js +11 -0
  16. recce/data/_next/static/52aV_JrNUZU6dMFgvTQEO/_clientMiddlewareManifest.json +1 -0
  17. recce/data/_next/static/chunks/02b996c7f6a29a06.js +4 -0
  18. recce/data/_next/static/chunks/19c10d219a6a21ff.js +1 -0
  19. recce/data/_next/static/chunks/2df9ec28a061971d.js +11 -0
  20. recce/data/_next/static/chunks/3098c987393bda15.js +1 -0
  21. recce/data/_next/static/chunks/393dc43e483f717a.css +2 -0
  22. recce/data/_next/static/chunks/399e8d91a7e45073.js +2 -0
  23. recce/data/_next/static/chunks/4d0186f631230245.js +1 -0
  24. recce/data/_next/static/chunks/5794ba9e10a9c060.js +11 -0
  25. recce/data/_next/static/chunks/715761c929a3f28b.js +110 -0
  26. recce/data/_next/static/chunks/71f88fcc615bf282.js +1 -0
  27. recce/data/_next/static/chunks/80d2a95eaf1201ea.js +1 -0
  28. recce/data/_next/static/chunks/9979c6109bbbee35.js +1 -0
  29. recce/data/_next/static/chunks/99d638224186c118.js +1 -0
  30. recce/data/_next/static/chunks/d003eb36240e92f3.js +1 -0
  31. recce/data/_next/static/chunks/d3167cdfec4fc351.js +1 -0
  32. recce/data/_next/static/chunks/e124bccf574a3361.css +1 -0
  33. recce/data/_next/static/chunks/f40141db1bdb46f0.css +6 -0
  34. recce/data/_next/static/chunks/fcc53a88741a52f9.js +1 -0
  35. recce/data/_next/static/chunks/turbopack-b1920d28cfb1f28d.js +3 -0
  36. recce/data/_next/static/media/favicon.a8d38d84.ico +0 -0
  37. recce/data/_next/static/media/montserrat-cyrillic-800-normal.d80d830d.woff2 +0 -0
  38. recce/data/_next/static/media/{montserrat-cyrillic-800-normal.bd5c9f50.woff → montserrat-cyrillic-800-normal.f9d58125.woff} +0 -0
  39. recce/data/_next/static/media/montserrat-cyrillic-ext-800-normal.076c2a93.woff2 +0 -0
  40. recce/data/_next/static/media/montserrat-latin-800-normal.cde454cc.woff2 +0 -0
  41. recce/data/_next/static/media/{montserrat-latin-800-normal.fc315020.woff → montserrat-latin-800-normal.d5761935.woff} +0 -0
  42. recce/data/_next/static/media/montserrat-latin-ext-800-normal.40ec0659.woff2 +0 -0
  43. recce/data/_next/static/media/{montserrat-latin-ext-800-normal.2e5381b2.woff → montserrat-latin-ext-800-normal.b671449b.woff} +0 -0
  44. recce/data/_next/static/media/{montserrat-vietnamese-800-normal.20c545e6.woff → montserrat-vietnamese-800-normal.9f7b8541.woff} +0 -0
  45. recce/data/_next/static/media/montserrat-vietnamese-800-normal.f9eb854e.woff2 +0 -0
  46. recce/data/_not-found/__next._full.txt +17 -0
  47. recce/data/_not-found/__next._head.txt +8 -0
  48. recce/data/_not-found/__next._index.txt +8 -0
  49. recce/data/_not-found/__next._not-found.__PAGE__.txt +5 -0
  50. recce/data/_not-found/__next._not-found.txt +4 -0
  51. recce/data/_not-found/__next._tree.txt +3 -0
  52. recce/data/_not-found.html +1 -0
  53. recce/data/_not-found.txt +17 -0
  54. recce/data/index.html +1 -1
  55. recce/data/index.txt +21 -23
  56. recce/event/__init__.py +9 -8
  57. recce/event/collector.py +3 -1
  58. recce/event/track.py +10 -0
  59. recce/github.py +1 -1
  60. recce/mcp_server.py +716 -0
  61. recce/models/types.py +35 -2
  62. recce/pull_request.py +1 -1
  63. recce/run.py +2 -2
  64. recce/server.py +105 -3
  65. recce/state/__init__.py +31 -0
  66. recce/state/cloud.py +632 -0
  67. recce/state/const.py +26 -0
  68. recce/state/local.py +56 -0
  69. recce/state/state.py +119 -0
  70. recce/state/state_loader.py +174 -0
  71. recce/summary.py +21 -1
  72. recce/tasks/dataframe.py +63 -1
  73. recce/tasks/rowcount.py +4 -1
  74. recce/tasks/schema.py +4 -1
  75. recce/util/api_token.py +9 -2
  76. recce/util/breaking.py +1 -1
  77. recce/util/io.py +2 -2
  78. recce/util/lineage.py +14 -18
  79. recce/util/recce_cloud.py +187 -7
  80. recce/yaml/__init__.py +2 -2
  81. recce_cloud/__init__.py +24 -0
  82. recce_cloud/api/__init__.py +17 -0
  83. recce_cloud/api/base.py +111 -0
  84. recce_cloud/api/client.py +150 -0
  85. recce_cloud/api/exceptions.py +26 -0
  86. recce_cloud/api/factory.py +63 -0
  87. recce_cloud/api/github.py +76 -0
  88. recce_cloud/api/gitlab.py +82 -0
  89. recce_cloud/artifact.py +57 -0
  90. recce_cloud/ci_providers/__init__.py +9 -0
  91. recce_cloud/ci_providers/base.py +82 -0
  92. recce_cloud/ci_providers/detector.py +147 -0
  93. recce_cloud/ci_providers/github_actions.py +136 -0
  94. recce_cloud/ci_providers/gitlab_ci.py +130 -0
  95. recce_cloud/cli.py +245 -0
  96. recce_cloud/upload.py +214 -0
  97. {recce_nightly-1.15.0.20250806.dist-info → recce_nightly-1.26.0.20251124.dist-info}/METADATA +54 -28
  98. recce_nightly-1.26.0.20251124.dist-info/RECORD +180 -0
  99. {recce_nightly-1.15.0.20250806.dist-info → recce_nightly-1.26.0.20251124.dist-info}/top_level.txt +1 -0
  100. tests/adapter/dbt_adapter/test_dbt_cll.py +4 -2
  101. tests/recce_cloud/__init__.py +0 -0
  102. tests/recce_cloud/test_ci_providers.py +351 -0
  103. tests/recce_cloud/test_cli.py +372 -0
  104. tests/recce_cloud/test_client.py +273 -0
  105. tests/recce_cloud/test_platform_clients.py +333 -0
  106. tests/test_cli.py +106 -3
  107. tests/test_cli_mcp_optional.py +45 -0
  108. tests/test_cloud_listing_cli.py +324 -0
  109. tests/test_core.py +147 -0
  110. tests/test_mcp_server.py +332 -0
  111. tests/test_server.py +6 -6
  112. tests/test_summary.py +14 -6
  113. recce/data/_next/static/Q_5ThPsmamd4VAGXuqwgi/_buildManifest.js +0 -1
  114. recce/data/_next/static/chunks/0376eeba-3db2196398d62270.js +0 -1
  115. recce/data/_next/static/chunks/068b80ea-833a129468ee1622.js +0 -1
  116. recce/data/_next/static/chunks/0ddaf06c-c7961285f66460f6.js +0 -1
  117. recce/data/_next/static/chunks/1268aea1-6dc1251c01bd724b.js +0 -54
  118. recce/data/_next/static/chunks/12f8fac4-16838e42d28d45c3.js +0 -1
  119. recce/data/_next/static/chunks/235b8375-8c84c51d7bd4f6aa.js +0 -1
  120. recce/data/_next/static/chunks/2541941f-2cd3a7c2d629bd33.js +0 -1
  121. recce/data/_next/static/chunks/273-f3fa401bd2b6fc91.js +0 -10
  122. recce/data/_next/static/chunks/2fc37c1e-910deebeb3d77c90.js +0 -1
  123. recce/data/_next/static/chunks/338-2e7eed5135c64550.js +0 -30
  124. recce/data/_next/static/chunks/367-ab8b16dd5f8586ca.js +0 -1
  125. recce/data/_next/static/chunks/3a92ee20-0400ffe460c7c803.js +0 -1
  126. recce/data/_next/static/chunks/62446465-423c03bb8c1f59b6.js +0 -1
  127. recce/data/_next/static/chunks/6af7f9e9-60aa8706f49dae45.js +0 -1
  128. recce/data/_next/static/chunks/6cf54382-49d52ae6e564e2ac.js +0 -1
  129. recce/data/_next/static/chunks/6dc81886-78e2efe4538794ae.js +0 -1
  130. recce/data/_next/static/chunks/715e4acc-9e2e6df4eb3809d1.js +0 -1
  131. recce/data/_next/static/chunks/72-181b430654230f0e.js +0 -1
  132. recce/data/_next/static/chunks/786-774e3e3ed70a41b3.js +0 -1
  133. recce/data/_next/static/chunks/8d700b6a.7fe2c8c3f4e333a6.js +0 -1
  134. recce/data/_next/static/chunks/a69d64b4-d6890125a87b0aba.js +0 -1
  135. recce/data/_next/static/chunks/ae307f12-01100009689ace61.js +0 -1
  136. recce/data/_next/static/chunks/app/_not-found/page-c7ef8ed6dc07aaeb.js +0 -1
  137. recce/data/_next/static/chunks/app/layout-744f0a78e9e50e60.js +0 -1
  138. recce/data/_next/static/chunks/app/page-e8f798c2ae3f59c2.js +0 -1
  139. recce/data/_next/static/chunks/c0015c5c-82c219792582c104.js +0 -1
  140. recce/data/_next/static/chunks/d90cfbaa-e7d779b3912afeec.js +0 -1
  141. recce/data/_next/static/chunks/e07c302e-cd170429646873e1.js +0 -1
  142. recce/data/_next/static/chunks/fa5fb511-15fb438349ad5b97.js +0 -1
  143. recce/data/_next/static/chunks/framework-7950757d31580329.js +0 -1
  144. recce/data/_next/static/chunks/main-app-4df79eb11c34d43c.js +0 -1
  145. recce/data/_next/static/chunks/main-cd6c104af638214a.js +0 -1
  146. recce/data/_next/static/chunks/pages/_app-73008661edbd5e05.js +0 -1
  147. recce/data/_next/static/chunks/pages/_error-cf8bbdc3cf76c83f.js +0 -1
  148. recce/data/_next/static/chunks/webpack-84df6dd5ae3cf908.js +0 -1
  149. recce/data/_next/static/css/188a3a1687e2a064.css +0 -1
  150. recce/data/_next/static/css/8edca58d4abcf908.css +0 -14
  151. recce/data/_next/static/css/abdb9814a3dd18bb.css +0 -1
  152. recce/data/_next/static/css/c21263c1520b615b.css +0 -1
  153. recce/data/_next/static/media/montserrat-cyrillic-800-normal.22628180.woff2 +0 -0
  154. recce/data/_next/static/media/montserrat-cyrillic-ext-800-normal.94a63aea.woff2 +0 -0
  155. recce/data/_next/static/media/montserrat-latin-800-normal.6f8fa298.woff2 +0 -0
  156. recce/data/_next/static/media/montserrat-latin-ext-800-normal.013b84f9.woff2 +0 -0
  157. recce/data/_next/static/media/montserrat-vietnamese-800-normal.c0035377.woff2 +0 -0
  158. recce/state.py +0 -865
  159. recce_nightly-1.15.0.20250806.dist-info/RECORD +0 -156
  160. tests/test_state.py +0 -134
  161. /recce/data/_next/static/{Q_5ThPsmamd4VAGXuqwgi → 52aV_JrNUZU6dMFgvTQEO}/_ssgManifest.js +0 -0
  162. /recce/data/_next/static/chunks/{polyfills-42372ed130431b0a.js → a6dad97d9634a72d.js} +0 -0
  163. /recce/data/_next/static/media/{montserrat-cyrillic-ext-800-normal.e6e0d8d0.woff → montserrat-cyrillic-ext-800-normal.a4fa76b5.woff} +0 -0
  164. /recce/data/_next/static/media/{reload-image.79aabb7d.svg → reload-image.7aa931c7.svg} +0 -0
  165. {recce_nightly-1.15.0.20250806.dist-info → recce_nightly-1.26.0.20251124.dist-info}/WHEEL +0 -0
  166. {recce_nightly-1.15.0.20250806.dist-info → recce_nightly-1.26.0.20251124.dist-info}/entry_points.txt +0 -0
  167. {recce_nightly-1.15.0.20250806.dist-info → recce_nightly-1.26.0.20251124.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,333 @@
1
+ """
2
+ Tests for platform-specific Recce Cloud API clients.
3
+ """
4
+
5
+ import os
6
+ from unittest.mock import patch
7
+ from urllib.parse import urlparse
8
+
9
+ import pytest
10
+
11
+ from recce_cloud.api.factory import create_platform_client
12
+ from recce_cloud.api.github import GitHubRecceCloudClient
13
+ from recce_cloud.api.gitlab import GitLabRecceCloudClient
14
+ from recce_cloud.ci_providers.base import CIInfo
15
+
16
+
17
+ class TestGitHubRecceCloudClient:
18
+ """Tests for GitHub Actions API client."""
19
+
20
+ def test_init(self):
21
+ """Test client initialization."""
22
+ client = GitHubRecceCloudClient(token="test_token", repository="owner/repo")
23
+ assert client.token == "test_token"
24
+ assert client.repository == "owner/repo"
25
+ parsed = urlparse(client.api_host)
26
+ # Accept main domain or subdomains:
27
+ assert parsed.hostname == "cloud.datarecce.io" or (
28
+ parsed.hostname and parsed.hostname.endswith(".cloud.datarecce.io")
29
+ )
30
+
31
+ def test_touch_recce_session_pr(self):
32
+ """Test touch_recce_session for PR context."""
33
+ client = GitHubRecceCloudClient(token="test_token", repository="owner/repo")
34
+
35
+ with patch.object(client, "_make_request") as mock_request:
36
+ mock_request.return_value = {
37
+ "session_id": "test_session_id",
38
+ "manifest_upload_url": "https://s3.aws.com/manifest",
39
+ "catalog_upload_url": "https://s3.aws.com/catalog",
40
+ }
41
+
42
+ response = client.touch_recce_session(
43
+ branch="feature-branch", adapter_type="postgres", cr_number=123, session_type="cr"
44
+ )
45
+
46
+ assert response["session_id"] == "test_session_id"
47
+ assert response["manifest_upload_url"] == "https://s3.aws.com/manifest"
48
+ assert response["catalog_upload_url"] == "https://s3.aws.com/catalog"
49
+
50
+ # Verify correct API endpoint was called
51
+ mock_request.assert_called_once()
52
+ call_args = mock_request.call_args
53
+ assert call_args[0][0] == "POST"
54
+ assert "github/owner/repo/touch-recce-session" in call_args[0][1]
55
+ assert call_args[1]["json"]["branch"] == "feature-branch"
56
+ assert call_args[1]["json"]["adapter_type"] == "postgres"
57
+ assert call_args[1]["json"]["pr_number"] == 123
58
+
59
+ def test_touch_recce_session_base(self):
60
+ """Test touch_recce_session for base branch context."""
61
+ client = GitHubRecceCloudClient(token="test_token", repository="owner/repo")
62
+
63
+ with patch.object(client, "_make_request") as mock_request:
64
+ mock_request.return_value = {
65
+ "session_id": "base_session_id",
66
+ "manifest_upload_url": "https://s3.aws.com/manifest",
67
+ "catalog_upload_url": "https://s3.aws.com/catalog",
68
+ }
69
+
70
+ client.touch_recce_session(branch="main", adapter_type="snowflake")
71
+
72
+ # Verify pr_number is not in the payload when cr_number is None
73
+ call_args = mock_request.call_args
74
+ assert "pr_number" not in call_args[1]["json"]
75
+
76
+ def test_touch_recce_session_prod_type(self):
77
+ """Test touch_recce_session with --type prod (should not include pr_number)."""
78
+ client = GitHubRecceCloudClient(token="test_token", repository="owner/repo")
79
+
80
+ with patch.object(client, "_make_request") as mock_request:
81
+ mock_request.return_value = {
82
+ "session_id": "prod_session_id",
83
+ "manifest_upload_url": "https://s3.aws.com/manifest",
84
+ "catalog_upload_url": "https://s3.aws.com/catalog",
85
+ }
86
+
87
+ # Even with cr_number detected, session_type="prod" should omit pr_number
88
+ client.touch_recce_session(branch="main", adapter_type="postgres", cr_number=123, session_type="prod")
89
+
90
+ # Verify pr_number is NOT in the payload when session_type is "prod"
91
+ call_args = mock_request.call_args
92
+ assert "pr_number" not in call_args[1]["json"]
93
+ assert call_args[1]["json"]["branch"] == "main"
94
+ assert call_args[1]["json"]["adapter_type"] == "postgres"
95
+
96
+ def test_upload_completed(self):
97
+ """Test upload_completed notification."""
98
+ client = GitHubRecceCloudClient(token="test_token", repository="owner/repo")
99
+
100
+ with patch.object(client, "_make_request") as mock_request:
101
+ mock_request.return_value = {}
102
+
103
+ client.upload_completed(session_id="test_session_id")
104
+
105
+ mock_request.assert_called_once()
106
+ call_args = mock_request.call_args
107
+ assert call_args[0][0] == "POST"
108
+ assert "github/owner/repo/upload-completed" in call_args[0][1]
109
+ assert call_args[1]["json"]["session_id"] == "test_session_id"
110
+
111
+
112
+ class TestGitLabRecceCloudClient:
113
+ """Tests for GitLab CI API client."""
114
+
115
+ def test_init(self):
116
+ """Test client initialization."""
117
+ client = GitLabRecceCloudClient(
118
+ token="test_token",
119
+ project_path="group/project",
120
+ repository_url="https://gitlab.com/group/project",
121
+ )
122
+ assert client.token == "test_token"
123
+ assert client.project_path == "group/project"
124
+ assert client.repository_url == "https://gitlab.com/group/project"
125
+
126
+ def test_touch_recce_session_mr(self):
127
+ """Test touch_recce_session for MR context."""
128
+ client = GitLabRecceCloudClient(
129
+ token="test_token",
130
+ project_path="group/project",
131
+ repository_url="https://gitlab.com/group/project",
132
+ )
133
+
134
+ with patch.object(client, "_make_request") as mock_request:
135
+ mock_request.return_value = {
136
+ "session_id": "test_session_id",
137
+ "manifest_upload_url": "https://s3.aws.com/manifest",
138
+ "catalog_upload_url": "https://s3.aws.com/catalog",
139
+ }
140
+
141
+ response = client.touch_recce_session(
142
+ branch="feature-branch",
143
+ adapter_type="postgres",
144
+ cr_number=456,
145
+ commit_sha="abc123def456",
146
+ session_type="cr",
147
+ )
148
+
149
+ assert response["session_id"] == "test_session_id"
150
+
151
+ # Verify correct API endpoint and payload
152
+ call_args = mock_request.call_args
153
+ assert call_args[0][0] == "POST"
154
+ assert "gitlab/group/project/touch-recce-session" in call_args[0][1]
155
+ payload = call_args[1]["json"]
156
+ assert payload["branch"] == "feature-branch"
157
+ assert payload["adapter_type"] == "postgres"
158
+ assert payload["mr_iid"] == 456
159
+ assert payload["commit_sha"] == "abc123def456"
160
+ assert payload["repository_url"] == "https://gitlab.com/group/project"
161
+
162
+ def test_touch_recce_session_base(self):
163
+ """Test touch_recce_session for base branch context."""
164
+ client = GitLabRecceCloudClient(
165
+ token="test_token",
166
+ project_path="group/project",
167
+ repository_url="https://gitlab.com/group/project",
168
+ )
169
+
170
+ with patch.object(client, "_make_request") as mock_request:
171
+ mock_request.return_value = {
172
+ "session_id": "base_session_id",
173
+ "manifest_upload_url": "https://s3.aws.com/manifest",
174
+ "catalog_upload_url": "https://s3.aws.com/catalog",
175
+ }
176
+
177
+ client.touch_recce_session(branch="main", adapter_type="bigquery", commit_sha="base123")
178
+
179
+ # Verify mr_iid is not in the payload when cr_number is None
180
+ call_args = mock_request.call_args
181
+ assert "mr_iid" not in call_args[1]["json"]
182
+
183
+ def test_touch_recce_session_prod_type(self):
184
+ """Test touch_recce_session with --type prod (should not include mr_iid)."""
185
+ client = GitLabRecceCloudClient(
186
+ token="test_token",
187
+ project_path="group/project",
188
+ repository_url="https://gitlab.com/group/project",
189
+ )
190
+
191
+ with patch.object(client, "_make_request") as mock_request:
192
+ mock_request.return_value = {
193
+ "session_id": "prod_session_id",
194
+ "manifest_upload_url": "https://s3.aws.com/manifest",
195
+ "catalog_upload_url": "https://s3.aws.com/catalog",
196
+ }
197
+
198
+ # Even with cr_number detected, session_type="prod" should omit mr_iid
199
+ client.touch_recce_session(
200
+ branch="main",
201
+ adapter_type="postgres",
202
+ cr_number=456,
203
+ commit_sha="abc123def456",
204
+ session_type="prod",
205
+ )
206
+
207
+ # Verify mr_iid is NOT in the payload when session_type is "prod"
208
+ call_args = mock_request.call_args
209
+ assert "mr_iid" not in call_args[1]["json"]
210
+ assert call_args[1]["json"]["branch"] == "main"
211
+ assert call_args[1]["json"]["adapter_type"] == "postgres"
212
+ assert call_args[1]["json"]["commit_sha"] == "abc123def456"
213
+
214
+ def test_upload_completed(self):
215
+ """Test upload_completed notification."""
216
+ client = GitLabRecceCloudClient(
217
+ token="test_token",
218
+ project_path="group/project",
219
+ repository_url="https://gitlab.com/group/project",
220
+ )
221
+
222
+ with patch.object(client, "_make_request") as mock_request:
223
+ mock_request.return_value = {}
224
+
225
+ client.upload_completed(session_id="test_session_id", commit_sha="commit456")
226
+
227
+ mock_request.assert_called_once()
228
+ call_args = mock_request.call_args
229
+ assert call_args[0][0] == "POST"
230
+ assert "gitlab/group/project/upload-completed" in call_args[0][1]
231
+ payload = call_args[1]["json"]
232
+ assert payload["session_id"] == "test_session_id"
233
+ assert payload["commit_sha"] == "commit456"
234
+
235
+
236
+ class TestFactoryCreatePlatformClient:
237
+ """Tests for create_platform_client factory function."""
238
+
239
+ def test_create_github_client(self):
240
+ """Test creating GitHub client."""
241
+ ci_info = CIInfo(platform="github-actions", repository="owner/repo")
242
+
243
+ client = create_platform_client(token="test_token", ci_info=ci_info)
244
+
245
+ assert isinstance(client, GitHubRecceCloudClient)
246
+ assert client.repository == "owner/repo"
247
+
248
+ def test_create_github_client_from_env(self):
249
+ """Test creating GitHub client with environment fallback."""
250
+ with patch.dict(os.environ, {"GITHUB_REPOSITORY": "owner/repo"}):
251
+ ci_info = CIInfo(platform="github-actions")
252
+
253
+ client = create_platform_client(token="test_token", ci_info=ci_info)
254
+
255
+ assert isinstance(client, GitHubRecceCloudClient)
256
+ assert client.repository == "owner/repo"
257
+
258
+ def test_create_github_client_missing_repository(self):
259
+ """Test error when GitHub repository information is missing."""
260
+ ci_info = CIInfo(platform="github-actions")
261
+
262
+ with patch.dict(os.environ, {}, clear=True):
263
+ with pytest.raises(ValueError, match="GitHub repository information is required"):
264
+ create_platform_client(token="test_token", ci_info=ci_info)
265
+
266
+ def test_create_gitlab_client(self):
267
+ """Test creating GitLab client."""
268
+ with patch.dict(
269
+ os.environ,
270
+ {"CI_PROJECT_URL": "https://gitlab.com/group/project"},
271
+ ):
272
+ ci_info = CIInfo(platform="gitlab-ci", repository="group/project")
273
+
274
+ client = create_platform_client(token="test_token", ci_info=ci_info)
275
+
276
+ assert isinstance(client, GitLabRecceCloudClient)
277
+ assert client.project_path == "group/project"
278
+ assert client.repository_url == "https://gitlab.com/group/project"
279
+
280
+ def test_create_gitlab_client_from_env(self):
281
+ """Test creating GitLab client with environment fallback."""
282
+ with patch.dict(
283
+ os.environ,
284
+ {
285
+ "CI_PROJECT_PATH": "group/project",
286
+ "CI_PROJECT_URL": "https://gitlab.com/group/project",
287
+ },
288
+ ):
289
+ ci_info = CIInfo(platform="gitlab-ci")
290
+
291
+ client = create_platform_client(token="test_token", ci_info=ci_info)
292
+
293
+ assert isinstance(client, GitLabRecceCloudClient)
294
+ assert client.project_path == "group/project"
295
+
296
+ def test_create_gitlab_client_missing_project_path(self):
297
+ """Test error when GitLab project path is missing."""
298
+ with patch.dict(os.environ, {}, clear=True):
299
+ ci_info = CIInfo(platform="gitlab-ci")
300
+
301
+ with pytest.raises(ValueError, match="GitLab project path is required"):
302
+ create_platform_client(token="test_token", ci_info=ci_info)
303
+
304
+ def test_create_gitlab_client_missing_project_url(self):
305
+ """Test error when GitLab project URL is missing."""
306
+ with patch.dict(os.environ, {"CI_PROJECT_PATH": "group/project"}, clear=True):
307
+ ci_info = CIInfo(platform="gitlab-ci", repository="group/project")
308
+
309
+ with pytest.raises(ValueError, match="GitLab project URL is required"):
310
+ create_platform_client(token="test_token", ci_info=ci_info)
311
+
312
+ def test_create_client_unsupported_platform(self):
313
+ """Test error for unsupported platform."""
314
+ ci_info = CIInfo(platform="unsupported-ci")
315
+
316
+ with pytest.raises(ValueError, match="Unsupported platform"):
317
+ create_platform_client(token="test_token", ci_info=ci_info)
318
+
319
+ def test_auto_detect_ci_info(self):
320
+ """Test automatic CI detection when ci_info is not provided."""
321
+ with patch.dict(
322
+ os.environ,
323
+ {
324
+ "GITHUB_ACTIONS": "true",
325
+ "GITHUB_REPOSITORY": "owner/repo",
326
+ "GITHUB_SHA": "abc123",
327
+ },
328
+ clear=True,
329
+ ):
330
+ client = create_platform_client(token="test_token")
331
+
332
+ assert isinstance(client, GitHubRecceCloudClient)
333
+ assert client.repository == "owner/repo"
tests/test_cli.py CHANGED
@@ -5,8 +5,10 @@ from click.testing import CliRunner
5
5
 
6
6
  from recce.cli import run as cli_command_run
7
7
  from recce.cli import server as cli_command_server
8
+ from recce.cli import snapshot as cli_command_snapshot
9
+ from recce.cli import upload_session as cli_command_upload_session
8
10
  from recce.core import RecceContext
9
- from recce.state import RecceStateLoader
11
+ from recce.state import CloudStateLoader
10
12
 
11
13
 
12
14
  def test_cmd_version():
@@ -48,11 +50,11 @@ class TestCommandServer(TestCase):
48
50
  @patch.object(RecceContext, "verify_required_artifacts")
49
51
  @patch("recce.util.recce_cloud.get_recce_cloud_onboarding_state")
50
52
  @patch("recce.cli.uvicorn.run")
51
- @patch("recce.cli.RecceStateLoader")
53
+ @patch("recce.cli.CloudStateLoader")
52
54
  def test_cmd_server_with_cloud(
53
55
  self, mock_state_loader_class, mock_run, mock_get_recce_cloud_onboarding_state, mock_verify_required_artifacts
54
56
  ):
55
- mock_state_loader = MagicMock(spec=RecceStateLoader)
57
+ mock_state_loader = MagicMock(spec=CloudStateLoader)
56
58
  mock_state_loader.verify.return_value = True
57
59
  mock_state_loader.review_mode = True
58
60
  mock_get_recce_cloud_onboarding_state.return_value = "completed"
@@ -65,6 +67,82 @@ class TestCommandServer(TestCase):
65
67
  mock_state_loader_class.assert_called_once()
66
68
  mock_run.assert_called_once()
67
69
 
70
+ @patch.object(RecceContext, "verify_required_artifacts")
71
+ @patch("recce.util.recce_cloud.get_recce_cloud_onboarding_state")
72
+ @patch("recce.cli.uvicorn.run")
73
+ @patch("recce.cli.CloudStateLoader")
74
+ @patch("recce.cli.prepare_api_token", return_value="test_api_token")
75
+ def test_cmd_server_with_session_id(
76
+ self,
77
+ mock_prepare_api_token,
78
+ mock_state_loader_class,
79
+ mock_run,
80
+ mock_get_recce_cloud_onboarding_state,
81
+ mock_verify_required_artifacts,
82
+ ):
83
+ """Test that --session-id automatically enables cloud and review mode"""
84
+ mock_state_loader = MagicMock(spec=CloudStateLoader)
85
+ mock_state_loader.verify.return_value = True
86
+ mock_state_loader.review_mode = True
87
+ mock_get_recce_cloud_onboarding_state.return_value = "completed"
88
+ mock_verify_required_artifacts.return_value = True, None
89
+
90
+ mock_state_loader_class.return_value = mock_state_loader
91
+
92
+ # Test with --session-id (should automatically enable cloud and review)
93
+ result = self.runner.invoke(cli_command_server, ["--session-id", "test-session-123", "--single-env"])
94
+
95
+ # Should succeed
96
+ assert result.exit_code == 0
97
+
98
+ # Should create CloudStateLoader with session_id in cloud_options
99
+ mock_state_loader_class.assert_called_once()
100
+ call_args = mock_state_loader_class.call_args
101
+ assert call_args.kwargs["review_mode"] is True
102
+ assert "session_id" in call_args.kwargs["cloud_options"]
103
+ assert call_args.kwargs["cloud_options"]["session_id"] == "test-session-123"
104
+
105
+ mock_run.assert_called_once()
106
+
107
+ @patch.object(RecceContext, "verify_required_artifacts")
108
+ @patch("recce.util.recce_cloud.get_recce_cloud_onboarding_state")
109
+ @patch("recce.cli.uvicorn.run")
110
+ @patch("recce.cli.CloudStateLoader")
111
+ @patch("recce.cli.prepare_api_token", return_value="test_api_token")
112
+ def test_cmd_server_with_share_url(
113
+ self,
114
+ mock_prepare_api_token,
115
+ mock_state_loader_class,
116
+ mock_run,
117
+ mock_get_recce_cloud_onboarding_state,
118
+ mock_verify_required_artifacts,
119
+ ):
120
+ """Test that --share-url automatically enables cloud and review mode"""
121
+ mock_state_loader = MagicMock(spec=CloudStateLoader)
122
+ mock_state_loader.verify.return_value = True
123
+ mock_state_loader.review_mode = True
124
+ mock_get_recce_cloud_onboarding_state.return_value = "completed"
125
+ mock_verify_required_artifacts.return_value = True, None
126
+
127
+ mock_state_loader_class.return_value = mock_state_loader
128
+
129
+ # Test with --share-url (should automatically enable cloud and review)
130
+ result = self.runner.invoke(
131
+ cli_command_server, ["--share-url", "https://cloud.recce.io/share/abc123", "--single-env"]
132
+ )
133
+
134
+ # Should succeed
135
+ assert result.exit_code == 0
136
+
137
+ # Should create CloudStateLoader with share_id in cloud_options
138
+ mock_state_loader_class.assert_called_once()
139
+ call_args = mock_state_loader_class.call_args
140
+ assert call_args.kwargs["review_mode"] is True
141
+ assert "share_id" in call_args.kwargs["cloud_options"]
142
+ assert call_args.kwargs["cloud_options"]["share_id"] == "abc123"
143
+
144
+ mock_run.assert_called_once()
145
+
68
146
  @patch.object(RecceContext, "verify_required_artifacts")
69
147
  @patch("os.path.isdir", side_effect=lambda path: True if path == "existed_folder" else False)
70
148
  @patch("recce.cli.uvicorn.run")
@@ -131,3 +209,28 @@ class TestCommandRun(TestCase):
131
209
 
132
210
  self.runner.invoke(cli_command_run, [])
133
211
  mock_cli_run.assert_called_once()
212
+
213
+
214
+ class TestCommandUploadSession(TestCase):
215
+ def setUp(self):
216
+ self.runner = CliRunner()
217
+ pass
218
+
219
+ @patch("recce.cli.prepare_api_token", return_value="unittest_token")
220
+ @patch("recce.cli.upload_artifacts_to_session", return_value=0)
221
+ def test_cmd_upload_session(self, mock_upload_artifacts_to_session, mock_prepare_api_token):
222
+ self.runner.invoke(
223
+ cli_command_upload_session,
224
+ ["--session-id", "unittest_session", "--api-token", mock_prepare_api_token.return_value],
225
+ )
226
+ mock_upload_artifacts_to_session.assert_called_once_with(
227
+ "target", session_id="unittest_session", token="unittest_token", debug=False
228
+ )
229
+
230
+ self.runner.invoke(
231
+ cli_command_snapshot,
232
+ ["--snapshot-id", "unittest_session", "--api-token", mock_prepare_api_token.return_value],
233
+ )
234
+ mock_upload_artifacts_to_session.assert_called_once_with(
235
+ "target", session_id="unittest_session", token="unittest_token", debug=False
236
+ )
@@ -0,0 +1,45 @@
1
+ """
2
+ Test that CLI can be imported even when mcp is not available.
3
+ """
4
+
5
+ import sys
6
+ from unittest.mock import patch
7
+
8
+
9
+ def test_cli_can_be_imported_without_mcp():
10
+ """Test that recce.cli can be imported even if mcp package is not available"""
11
+ # This test verifies that the CLI module doesn't fail to import
12
+ # when mcp is not installed, since mcp is an optional dependency
13
+ from recce import cli
14
+
15
+ assert cli is not None
16
+ assert hasattr(cli, "cli")
17
+ assert hasattr(cli, "mcp_server")
18
+
19
+
20
+ def test_mcp_server_command_fails_gracefully_without_mcp():
21
+ """Test that mcp-server command shows helpful error when mcp is not available"""
22
+ # Mock sys.modules to simulate mcp not being installed
23
+ with patch.dict(sys.modules, {"mcp": None, "mcp.server": None, "mcp.server.stdio": None, "mcp.types": None}):
24
+ # Remove mcp_server from modules to force reimport
25
+ if "recce.mcp_server" in sys.modules:
26
+ del sys.modules["recce.mcp_server"]
27
+
28
+ from recce.cli import mcp_server
29
+
30
+ # The function should exist
31
+ assert mcp_server is not None
32
+
33
+ # When called, it should handle ImportError gracefully
34
+ # (We can't easily test the actual execution without more mocking,
35
+ # but we've verified the function exists and can be imported)
36
+
37
+
38
+ def test_cli_command_exists():
39
+ """Test that both server and mcp-server commands are registered"""
40
+ from recce.cli import cli
41
+
42
+ # Check that both commands exist
43
+ commands = {cmd.name for cmd in cli.commands.values()}
44
+ assert "server" in commands
45
+ assert "mcp_server" in commands or "mcp-server" in commands