recce-nightly 1.2.0.20250506__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 (213) hide show
  1. recce/VERSION +1 -1
  2. recce/__init__.py +27 -22
  3. recce/adapter/base.py +11 -14
  4. recce/adapter/dbt_adapter/__init__.py +810 -480
  5. recce/adapter/dbt_adapter/dbt_version.py +3 -0
  6. recce/adapter/sqlmesh_adapter.py +24 -35
  7. recce/apis/check_api.py +39 -28
  8. recce/apis/check_func.py +33 -27
  9. recce/apis/run_api.py +25 -19
  10. recce/apis/run_func.py +29 -23
  11. recce/artifact.py +119 -51
  12. recce/cli.py +1299 -323
  13. recce/config.py +42 -33
  14. recce/connect_to_cloud.py +138 -0
  15. recce/core.py +55 -47
  16. recce/data/404.html +1 -1
  17. recce/data/__next.__PAGE__.txt +10 -0
  18. recce/data/__next._full.txt +23 -0
  19. recce/data/__next._head.txt +8 -0
  20. recce/data/__next._index.txt +8 -0
  21. recce/data/__next._tree.txt +5 -0
  22. recce/data/_next/static/52aV_JrNUZU6dMFgvTQEO/_buildManifest.js +11 -0
  23. recce/data/_next/static/52aV_JrNUZU6dMFgvTQEO/_clientMiddlewareManifest.json +1 -0
  24. recce/data/_next/static/chunks/02b996c7f6a29a06.js +4 -0
  25. recce/data/_next/static/chunks/19c10d219a6a21ff.js +1 -0
  26. recce/data/_next/static/chunks/2df9ec28a061971d.js +11 -0
  27. recce/data/_next/static/chunks/3098c987393bda15.js +1 -0
  28. recce/data/_next/static/chunks/393dc43e483f717a.css +2 -0
  29. recce/data/_next/static/chunks/399e8d91a7e45073.js +2 -0
  30. recce/data/_next/static/chunks/4d0186f631230245.js +1 -0
  31. recce/data/_next/static/chunks/5794ba9e10a9c060.js +11 -0
  32. recce/data/_next/static/chunks/715761c929a3f28b.js +110 -0
  33. recce/data/_next/static/chunks/71f88fcc615bf282.js +1 -0
  34. recce/data/_next/static/chunks/80d2a95eaf1201ea.js +1 -0
  35. recce/data/_next/static/chunks/9979c6109bbbee35.js +1 -0
  36. recce/data/_next/static/chunks/99d638224186c118.js +1 -0
  37. recce/data/_next/static/chunks/d003eb36240e92f3.js +1 -0
  38. recce/data/_next/static/chunks/d3167cdfec4fc351.js +1 -0
  39. recce/data/_next/static/chunks/e124bccf574a3361.css +1 -0
  40. recce/data/_next/static/chunks/f40141db1bdb46f0.css +6 -0
  41. recce/data/_next/static/chunks/fcc53a88741a52f9.js +1 -0
  42. recce/data/_next/static/chunks/turbopack-b1920d28cfb1f28d.js +3 -0
  43. recce/data/_next/static/media/favicon.a8d38d84.ico +0 -0
  44. recce/data/_next/static/media/montserrat-cyrillic-800-normal.d80d830d.woff2 +0 -0
  45. recce/data/_next/static/media/montserrat-cyrillic-800-normal.f9d58125.woff +0 -0
  46. recce/data/_next/static/media/montserrat-cyrillic-ext-800-normal.076c2a93.woff2 +0 -0
  47. recce/data/_next/static/media/montserrat-cyrillic-ext-800-normal.a4fa76b5.woff +0 -0
  48. recce/data/_next/static/media/montserrat-latin-800-normal.cde454cc.woff2 +0 -0
  49. recce/data/_next/static/media/montserrat-latin-800-normal.d5761935.woff +0 -0
  50. recce/data/_next/static/media/montserrat-latin-ext-800-normal.40ec0659.woff2 +0 -0
  51. recce/data/_next/static/media/montserrat-latin-ext-800-normal.b671449b.woff +0 -0
  52. recce/data/_next/static/media/montserrat-vietnamese-800-normal.9f7b8541.woff +0 -0
  53. recce/data/_next/static/media/montserrat-vietnamese-800-normal.f9eb854e.woff2 +0 -0
  54. recce/data/_next/static/media/reload-image.7aa931c7.svg +4 -0
  55. recce/data/_not-found/__next._full.txt +17 -0
  56. recce/data/_not-found/__next._head.txt +8 -0
  57. recce/data/_not-found/__next._index.txt +8 -0
  58. recce/data/_not-found/__next._not-found.__PAGE__.txt +5 -0
  59. recce/data/_not-found/__next._not-found.txt +4 -0
  60. recce/data/_not-found/__next._tree.txt +3 -0
  61. recce/data/_not-found.html +1 -0
  62. recce/data/_not-found.txt +17 -0
  63. recce/data/auth_callback.html +68 -0
  64. recce/data/imgs/reload-image.svg +4 -0
  65. recce/data/index.html +1 -27
  66. recce/data/index.txt +23 -7
  67. recce/diff.py +6 -12
  68. recce/event/__init__.py +86 -74
  69. recce/event/collector.py +33 -22
  70. recce/event/track.py +49 -27
  71. recce/exceptions.py +1 -1
  72. recce/git.py +7 -7
  73. recce/github.py +57 -53
  74. recce/mcp_server.py +716 -0
  75. recce/models/__init__.py +4 -1
  76. recce/models/check.py +6 -7
  77. recce/models/run.py +1 -0
  78. recce/models/types.py +131 -28
  79. recce/pull_request.py +27 -25
  80. recce/run.py +165 -121
  81. recce/server.py +303 -111
  82. recce/state/__init__.py +31 -0
  83. recce/state/cloud.py +632 -0
  84. recce/state/const.py +26 -0
  85. recce/state/local.py +56 -0
  86. recce/state/state.py +119 -0
  87. recce/state/state_loader.py +174 -0
  88. recce/summary.py +188 -143
  89. recce/tasks/__init__.py +19 -3
  90. recce/tasks/core.py +11 -13
  91. recce/tasks/dataframe.py +82 -18
  92. recce/tasks/histogram.py +69 -34
  93. recce/tasks/lineage.py +2 -2
  94. recce/tasks/profile.py +152 -86
  95. recce/tasks/query.py +139 -87
  96. recce/tasks/rowcount.py +37 -31
  97. recce/tasks/schema.py +18 -15
  98. recce/tasks/top_k.py +35 -35
  99. recce/tasks/valuediff.py +216 -152
  100. recce/util/__init__.py +3 -0
  101. recce/util/api_token.py +80 -0
  102. recce/util/breaking.py +87 -85
  103. recce/util/cll.py +274 -219
  104. recce/util/io.py +22 -17
  105. recce/util/lineage.py +65 -16
  106. recce/util/logger.py +1 -1
  107. recce/util/onboarding_state.py +45 -0
  108. recce/util/perf_tracking.py +85 -0
  109. recce/util/recce_cloud.py +322 -72
  110. recce/util/singleton.py +4 -4
  111. recce/yaml/__init__.py +7 -10
  112. recce_cloud/__init__.py +24 -0
  113. recce_cloud/api/__init__.py +17 -0
  114. recce_cloud/api/base.py +111 -0
  115. recce_cloud/api/client.py +150 -0
  116. recce_cloud/api/exceptions.py +26 -0
  117. recce_cloud/api/factory.py +63 -0
  118. recce_cloud/api/github.py +76 -0
  119. recce_cloud/api/gitlab.py +82 -0
  120. recce_cloud/artifact.py +57 -0
  121. recce_cloud/ci_providers/__init__.py +9 -0
  122. recce_cloud/ci_providers/base.py +82 -0
  123. recce_cloud/ci_providers/detector.py +147 -0
  124. recce_cloud/ci_providers/github_actions.py +136 -0
  125. recce_cloud/ci_providers/gitlab_ci.py +130 -0
  126. recce_cloud/cli.py +245 -0
  127. recce_cloud/upload.py +214 -0
  128. {recce_nightly-1.2.0.20250506.dist-info → recce_nightly-1.26.0.20251124.dist-info}/METADATA +68 -37
  129. recce_nightly-1.26.0.20251124.dist-info/RECORD +180 -0
  130. {recce_nightly-1.2.0.20250506.dist-info → recce_nightly-1.26.0.20251124.dist-info}/WHEEL +1 -1
  131. {recce_nightly-1.2.0.20250506.dist-info → recce_nightly-1.26.0.20251124.dist-info}/top_level.txt +1 -0
  132. tests/adapter/dbt_adapter/conftest.py +9 -5
  133. tests/adapter/dbt_adapter/dbt_test_helper.py +37 -22
  134. tests/adapter/dbt_adapter/test_dbt_adapter.py +0 -15
  135. tests/adapter/dbt_adapter/test_dbt_cll.py +656 -41
  136. tests/adapter/dbt_adapter/test_selector.py +22 -21
  137. tests/recce_cloud/__init__.py +0 -0
  138. tests/recce_cloud/test_ci_providers.py +351 -0
  139. tests/recce_cloud/test_cli.py +372 -0
  140. tests/recce_cloud/test_client.py +273 -0
  141. tests/recce_cloud/test_platform_clients.py +333 -0
  142. tests/tasks/conftest.py +1 -1
  143. tests/tasks/test_histogram.py +58 -66
  144. tests/tasks/test_lineage.py +36 -23
  145. tests/tasks/test_preset_checks.py +45 -31
  146. tests/tasks/test_profile.py +339 -15
  147. tests/tasks/test_query.py +46 -46
  148. tests/tasks/test_row_count.py +65 -46
  149. tests/tasks/test_schema.py +65 -42
  150. tests/tasks/test_top_k.py +22 -18
  151. tests/tasks/test_valuediff.py +43 -32
  152. tests/test_cli.py +174 -60
  153. tests/test_cli_mcp_optional.py +45 -0
  154. tests/test_cloud_listing_cli.py +324 -0
  155. tests/test_config.py +7 -9
  156. tests/test_connect_to_cloud.py +82 -0
  157. tests/test_core.py +151 -4
  158. tests/test_dbt.py +7 -7
  159. tests/test_mcp_server.py +332 -0
  160. tests/test_pull_request.py +1 -1
  161. tests/test_server.py +25 -19
  162. tests/test_summary.py +29 -17
  163. recce/data/_next/static/Kcbs3GEIyH2LxgLYat0es/_buildManifest.js +0 -1
  164. recce/data/_next/static/chunks/1f229bf6-d9fe92e56db8d93b.js +0 -1
  165. recce/data/_next/static/chunks/29e3cc0d-8c150e37dff9631b.js +0 -1
  166. recce/data/_next/static/chunks/368-7587b306577df275.js +0 -65
  167. recce/data/_next/static/chunks/36e1c10d-bb0210cbd6573a8d.js +0 -1
  168. recce/data/_next/static/chunks/3998a672-eaad84bdd88cc73e.js +0 -1
  169. recce/data/_next/static/chunks/3a92ee20-3b5d922d4157af5e.js +0 -1
  170. recce/data/_next/static/chunks/450c323b-1bb5db526e54435a.js +0 -1
  171. recce/data/_next/static/chunks/47d8844f-79a1b53c66a7d7ec.js +0 -1
  172. recce/data/_next/static/chunks/6dc81886-c94b9b91bc2c3caf.js +0 -1
  173. recce/data/_next/static/chunks/6ef81909-694dc38134099299.js +0 -1
  174. recce/data/_next/static/chunks/700-3b65fc3666820d00.js +0 -2
  175. recce/data/_next/static/chunks/7a8a3e83-d7fa409d97b38b2b.js +0 -1
  176. recce/data/_next/static/chunks/7f27ae6c-413f6b869a04183a.js +0 -1
  177. recce/data/_next/static/chunks/8d700b6a-f0b1f6b9e0d97ce2.js +0 -1
  178. recce/data/_next/static/chunks/9746af58-d74bef4d03eea6ab.js +0 -1
  179. recce/data/_next/static/chunks/a30376cd-7d806e1602f2dc3a.js +0 -1
  180. recce/data/_next/static/chunks/app/_not-found/page-8a886fa0855c3105.js +0 -1
  181. recce/data/_next/static/chunks/app/layout-9102e22cb73f74d6.js +0 -1
  182. recce/data/_next/static/chunks/app/page-cee661090afbd6aa.js +0 -1
  183. recce/data/_next/static/chunks/b63b1b3f-7395c74e11a14e95.js +0 -1
  184. recce/data/_next/static/chunks/c132bf7d-8102037f9ccf372a.js +0 -1
  185. recce/data/_next/static/chunks/c1ceaa8b-a1e442154d23515e.js +0 -1
  186. recce/data/_next/static/chunks/cd9f8d63-cf0d5a7b0f7a92e8.js +0 -54
  187. recce/data/_next/static/chunks/ce84277d-f42c2c58049cea2d.js +0 -1
  188. recce/data/_next/static/chunks/e24bf851-0f8cbc99656833e7.js +0 -1
  189. recce/data/_next/static/chunks/fee69bc6-f17d36c080742e74.js +0 -1
  190. recce/data/_next/static/chunks/framework-ded83d71b51ce901.js +0 -1
  191. recce/data/_next/static/chunks/main-a0859f1f36d0aa6c.js +0 -1
  192. recce/data/_next/static/chunks/main-app-0225a2255968e566.js +0 -1
  193. recce/data/_next/static/chunks/pages/_app-d5672bf3d8b6371b.js +0 -1
  194. recce/data/_next/static/chunks/pages/_error-ed75be3f25588548.js +0 -1
  195. recce/data/_next/static/chunks/webpack-567d72f0bc0820d5.js +0 -1
  196. recce/data/_next/static/css/c9ecb46a4b21c126.css +0 -14
  197. recce/data/_next/static/media/montserrat-cyrillic-800-normal.22628180.woff2 +0 -0
  198. recce/data/_next/static/media/montserrat-cyrillic-800-normal.31d693bb.woff +0 -0
  199. recce/data/_next/static/media/montserrat-cyrillic-ext-800-normal.7e2c1e62.woff +0 -0
  200. recce/data/_next/static/media/montserrat-cyrillic-ext-800-normal.94a63aea.woff2 +0 -0
  201. recce/data/_next/static/media/montserrat-latin-800-normal.6f8fa298.woff2 +0 -0
  202. recce/data/_next/static/media/montserrat-latin-800-normal.97e20d5e.woff +0 -0
  203. recce/data/_next/static/media/montserrat-latin-ext-800-normal.013b84f9.woff2 +0 -0
  204. recce/data/_next/static/media/montserrat-latin-ext-800-normal.aff52ab0.woff +0 -0
  205. recce/data/_next/static/media/montserrat-vietnamese-800-normal.5f21869b.woff +0 -0
  206. recce/data/_next/static/media/montserrat-vietnamese-800-normal.c0035377.woff2 +0 -0
  207. recce/state.py +0 -753
  208. recce_nightly-1.2.0.20250506.dist-info/RECORD +0 -142
  209. tests/test_state.py +0 -123
  210. /recce/data/_next/static/{Kcbs3GEIyH2LxgLYat0es → 52aV_JrNUZU6dMFgvTQEO}/_ssgManifest.js +0 -0
  211. /recce/data/_next/static/chunks/{polyfills-42372ed130431b0a.js → a6dad97d9634a72d.js} +0 -0
  212. {recce_nightly-1.2.0.20250506.dist-info → recce_nightly-1.26.0.20251124.dist-info}/entry_points.txt +0 -0
  213. {recce_nightly-1.2.0.20250506.dist-info → recce_nightly-1.26.0.20251124.dist-info}/licenses/LICENSE +0 -0
@@ -21,13 +21,13 @@ def test_select(dbt_test_helper):
21
21
  adapter: DbtAdapter = dbt_test_helper.context.adapter
22
22
 
23
23
  # Test methods
24
- node_ids = adapter.select_nodes('customers_1')
24
+ node_ids = adapter.select_nodes("customers_1")
25
25
  assert len(node_ids) == 1
26
- node_ids = adapter.select_nodes('resource_type:model')
26
+ node_ids = adapter.select_nodes("resource_type:model")
27
27
  assert len(node_ids) == 2
28
- node_ids = adapter.select_nodes('tag:test_tag')
28
+ node_ids = adapter.select_nodes("tag:test_tag")
29
29
  assert len(node_ids) == 2
30
- node_ids = adapter.select_nodes('tag:test_tag2')
30
+ node_ids = adapter.select_nodes("tag:test_tag2")
31
31
  assert len(node_ids) == 0
32
32
  node_ids = adapter.select_nodes("config.materialized:incremental")
33
33
  assert len(node_ids) == 0
@@ -47,9 +47,9 @@ def test_select(dbt_test_helper):
47
47
  assert len(node_ids) == 2
48
48
  node_ids = adapter.select_nodes("config.materialized:table,tag:test_tag2")
49
49
  assert len(node_ids) == 0
50
- node_ids = adapter.select_nodes(exclude='customers_1')
50
+ node_ids = adapter.select_nodes(exclude="customers_1")
51
51
  assert len(node_ids) == 1
52
- node_ids = adapter.select_nodes('customers_1', exclude='customers_2')
52
+ node_ids = adapter.select_nodes("customers_1", exclude="customers_2")
53
53
  assert len(node_ids) == 1
54
54
 
55
55
  # Test graph operation
@@ -62,10 +62,10 @@ def test_select(dbt_test_helper):
62
62
  dbt_test_helper.create_snapshot("snapshot_1", csv_data_base, csv_data_curr)
63
63
  dbt_test_helper.create_model("use_snapshot", csv_data_base, csv_data_base, depends_on=["snapshot_1"])
64
64
 
65
- node_ids = adapter.select_nodes('resource_type:snapshot')
65
+ node_ids = adapter.select_nodes("resource_type:snapshot")
66
66
  assert len(node_ids) == 1
67
67
 
68
- node_ids = adapter.select_nodes('resource_type:snapshot+')
68
+ node_ids = adapter.select_nodes("resource_type:snapshot+")
69
69
  assert len(node_ids) == 2
70
70
 
71
71
  node_ids = adapter.select_nodes("state:modified,resource_type:snapshot")
@@ -172,30 +172,31 @@ def test_select_with_pacakage_mode_include_exclude(dbt_test_helper):
172
172
  Only customers_3 is changed
173
173
  """
174
174
  dbt_test_helper.create_model("customers_1", csv_data_base, csv_data_base)
175
- dbt_test_helper.create_model("customers_2", csv_data_base, csv_data_base, depends_on=['customers_1'])
176
- dbt_test_helper.create_model("customers_3", csv_data_base, csv_data_curr, depends_on=['customers_2'])
177
- dbt_test_helper.create_model("customers_4", csv_data_base, csv_data_base, depends_on=['customers_3'],
178
- package_name='other_package')
179
- dbt_test_helper.create_model("customers_5", csv_data_base, csv_data_base, depends_on=['customers_3'])
175
+ dbt_test_helper.create_model("customers_2", csv_data_base, csv_data_base, depends_on=["customers_1"])
176
+ dbt_test_helper.create_model("customers_3", csv_data_base, csv_data_curr, depends_on=["customers_2"])
177
+ dbt_test_helper.create_model(
178
+ "customers_4", csv_data_base, csv_data_base, depends_on=["customers_3"], package_name="other_package"
179
+ )
180
+ dbt_test_helper.create_model("customers_5", csv_data_base, csv_data_base, depends_on=["customers_3"])
180
181
 
181
182
  adapter: DbtAdapter = dbt_test_helper.context.adapter
182
183
 
183
- node_ids = adapter.select_nodes(packages=['other_package'])
184
+ node_ids = adapter.select_nodes(packages=["other_package"])
184
185
  assert len(node_ids) == 1
185
186
 
186
- node_ids = adapter.select_nodes(view_mode='changed_models')
187
+ node_ids = adapter.select_nodes(view_mode="changed_models")
187
188
  assert len(node_ids) == 4
188
189
 
189
- node_ids = adapter.select_nodes(view_mode='changed_models', packages=['other_package'])
190
+ node_ids = adapter.select_nodes(view_mode="changed_models", packages=["other_package"])
190
191
  assert len(node_ids) == 1
191
192
 
192
- node_ids = adapter.select_nodes(view_mode='changed_models', packages=['other_package'], exclude='customers_4')
193
+ node_ids = adapter.select_nodes(view_mode="changed_models", packages=["other_package"], exclude="customers_4")
193
194
  assert len(node_ids) == 0
194
- node_ids = adapter.select_nodes(view_mode='changed_models', packages=['other_package'], select='customers_1+')
195
+ node_ids = adapter.select_nodes(view_mode="changed_models", packages=["other_package"], select="customers_1+")
195
196
  assert len(node_ids) == 1
196
- node_ids = adapter.select_nodes(view_mode='changed_models', select='+customers_5')
197
+ node_ids = adapter.select_nodes(view_mode="changed_models", select="+customers_5")
197
198
  assert len(node_ids) == 3
198
- node_ids = adapter.select_nodes(view_mode='all', select='+customers_5')
199
+ node_ids = adapter.select_nodes(view_mode="all", select="+customers_5")
199
200
  assert len(node_ids) == 4
200
- node_ids = adapter.select_nodes(select='+customers_5')
201
+ node_ids = adapter.select_nodes(select="+customers_5")
201
202
  assert len(node_ids) == 4
File without changes
@@ -0,0 +1,351 @@
1
+ """
2
+ Tests for CI provider detection and information extraction.
3
+ """
4
+
5
+ import json
6
+ import os
7
+ from unittest.mock import mock_open, patch
8
+
9
+ from recce_cloud.ci_providers import CIDetector, GitHubActionsProvider, GitLabCIProvider
10
+ from recce_cloud.ci_providers.base import CIInfo
11
+
12
+
13
+ class TestGitHubActionsProvider:
14
+ """Tests for GitHub Actions provider."""
15
+
16
+ def test_can_handle_true(self):
17
+ """Test detection when GITHUB_ACTIONS is true."""
18
+ with patch.dict(os.environ, {"GITHUB_ACTIONS": "true"}):
19
+ provider = GitHubActionsProvider()
20
+ assert provider.can_handle() is True
21
+
22
+ def test_can_handle_false(self):
23
+ """Test detection when GITHUB_ACTIONS is not set."""
24
+ with patch.dict(os.environ, {}, clear=True):
25
+ provider = GitHubActionsProvider()
26
+ assert provider.can_handle() is False
27
+
28
+ def test_extract_pr_number_from_event(self):
29
+ """Test PR number extraction from event JSON."""
30
+ event_data = {"pull_request": {"number": 123}}
31
+ mock_file = mock_open(read_data=json.dumps(event_data))
32
+
33
+ with patch.dict(os.environ, {"GITHUB_EVENT_PATH": "/tmp/event.json"}):
34
+ with patch("builtins.open", mock_file):
35
+ with patch("os.path.exists", return_value=True):
36
+ provider = GitHubActionsProvider()
37
+ pr_number = provider._extract_pr_number()
38
+ assert pr_number == 123
39
+
40
+ def test_extract_pr_number_no_event(self):
41
+ """Test PR number extraction when event path doesn't exist."""
42
+ with patch.dict(os.environ, {}, clear=True):
43
+ provider = GitHubActionsProvider()
44
+ pr_number = provider._extract_pr_number()
45
+ assert pr_number is None
46
+
47
+ def test_extract_commit_sha(self):
48
+ """Test commit SHA extraction."""
49
+ with patch.dict(os.environ, {"GITHUB_SHA": "abc123def456"}):
50
+ provider = GitHubActionsProvider()
51
+ commit_sha = provider._extract_commit_sha()
52
+ assert commit_sha == "abc123def456"
53
+
54
+ def test_extract_commit_sha_fallback(self):
55
+ """Test commit SHA extraction with git fallback."""
56
+ with patch.dict(os.environ, {}, clear=True):
57
+ with patch.object(GitHubActionsProvider, "run_git_command", return_value="git123456"):
58
+ provider = GitHubActionsProvider()
59
+ commit_sha = provider._extract_commit_sha()
60
+ assert commit_sha == "git123456"
61
+
62
+ def test_extract_base_branch(self):
63
+ """Test base branch extraction."""
64
+ with patch.dict(os.environ, {"GITHUB_BASE_REF": "main"}):
65
+ provider = GitHubActionsProvider()
66
+ base_branch = provider._extract_base_branch()
67
+ assert base_branch == "main"
68
+
69
+ def test_extract_base_branch_default(self):
70
+ """Test base branch extraction defaults to main."""
71
+ with patch.dict(os.environ, {}, clear=True):
72
+ provider = GitHubActionsProvider()
73
+ base_branch = provider._extract_base_branch()
74
+ assert base_branch == "main"
75
+
76
+ def test_extract_source_branch(self):
77
+ """Test source branch extraction."""
78
+ with patch.dict(os.environ, {"GITHUB_HEAD_REF": "feature-branch"}):
79
+ provider = GitHubActionsProvider()
80
+ source_branch = provider._extract_source_branch()
81
+ assert source_branch == "feature-branch"
82
+
83
+ def test_extract_source_branch_fallback(self):
84
+ """Test source branch extraction with fallback."""
85
+ with patch.dict(os.environ, {"GITHUB_REF_NAME": "develop"}, clear=True):
86
+ provider = GitHubActionsProvider()
87
+ source_branch = provider._extract_source_branch()
88
+ assert source_branch == "develop"
89
+
90
+ def test_extract_ci_info_pr_context(self):
91
+ """Test full CI info extraction in PR context."""
92
+ event_data = {"pull_request": {"number": 456}}
93
+ mock_file = mock_open(read_data=json.dumps(event_data))
94
+
95
+ with patch.dict(
96
+ os.environ,
97
+ {
98
+ "GITHUB_ACTIONS": "true",
99
+ "GITHUB_EVENT_PATH": "/tmp/event.json",
100
+ "GITHUB_SHA": "abc123",
101
+ "GITHUB_BASE_REF": "main",
102
+ "GITHUB_HEAD_REF": "feature",
103
+ "GITHUB_REPOSITORY": "owner/repo",
104
+ },
105
+ ):
106
+ with patch("builtins.open", mock_file):
107
+ with patch("os.path.exists", return_value=True):
108
+ provider = GitHubActionsProvider()
109
+ ci_info = provider.extract_ci_info()
110
+
111
+ assert ci_info.platform == "github-actions"
112
+ assert ci_info.cr_number == 456
113
+ assert ci_info.cr_url == "https://github.com/owner/repo/pull/456"
114
+ assert ci_info.session_type == "cr"
115
+ assert ci_info.commit_sha == "abc123"
116
+ assert ci_info.base_branch == "main"
117
+ assert ci_info.source_branch == "feature"
118
+ assert ci_info.repository == "owner/repo"
119
+
120
+ def test_extract_ci_info_main_branch(self):
121
+ """Test CI info extraction for main branch."""
122
+ with patch.dict(
123
+ os.environ,
124
+ {
125
+ "GITHUB_ACTIONS": "true",
126
+ "GITHUB_SHA": "main123",
127
+ "GITHUB_REF_NAME": "main",
128
+ "GITHUB_REPOSITORY": "owner/repo",
129
+ },
130
+ clear=True,
131
+ ):
132
+ provider = GitHubActionsProvider()
133
+ ci_info = provider.extract_ci_info()
134
+
135
+ assert ci_info.platform == "github-actions"
136
+ assert ci_info.cr_number is None
137
+ assert ci_info.session_type == "prod"
138
+ assert ci_info.source_branch == "main"
139
+
140
+ def test_extract_access_token(self):
141
+ """Test GITHUB_TOKEN detection."""
142
+ with patch.dict(
143
+ os.environ,
144
+ {
145
+ "GITHUB_ACTIONS": "true",
146
+ "GITHUB_TOKEN": "ghp_test123token456",
147
+ "GITHUB_SHA": "abc123",
148
+ },
149
+ clear=True,
150
+ ):
151
+ provider = GitHubActionsProvider()
152
+ ci_info = provider.extract_ci_info()
153
+
154
+ assert ci_info.access_token == "ghp_test123token456"
155
+
156
+ def test_extract_access_token_not_set(self):
157
+ """Test when GITHUB_TOKEN is not set."""
158
+ with patch.dict(os.environ, {"GITHUB_ACTIONS": "true", "GITHUB_SHA": "abc123"}, clear=True):
159
+ provider = GitHubActionsProvider()
160
+ ci_info = provider.extract_ci_info()
161
+
162
+ assert ci_info.access_token is None
163
+
164
+
165
+ class TestGitLabCIProvider:
166
+ """Tests for GitLab CI provider."""
167
+
168
+ def test_can_handle_true(self):
169
+ """Test detection when GITLAB_CI is true."""
170
+ with patch.dict(os.environ, {"GITLAB_CI": "true"}):
171
+ provider = GitLabCIProvider()
172
+ assert provider.can_handle() is True
173
+
174
+ def test_can_handle_false(self):
175
+ """Test detection when GITLAB_CI is not set."""
176
+ with patch.dict(os.environ, {}, clear=True):
177
+ provider = GitLabCIProvider()
178
+ assert provider.can_handle() is False
179
+
180
+ def test_extract_mr_number(self):
181
+ """Test MR number extraction."""
182
+ with patch.dict(os.environ, {"CI_MERGE_REQUEST_IID": "789"}):
183
+ provider = GitLabCIProvider()
184
+ mr_number = provider._extract_mr_number()
185
+ assert mr_number == 789
186
+
187
+ def test_extract_mr_number_none(self):
188
+ """Test MR number extraction when not set."""
189
+ with patch.dict(os.environ, {}, clear=True):
190
+ provider = GitLabCIProvider()
191
+ mr_number = provider._extract_mr_number()
192
+ assert mr_number is None
193
+
194
+ def test_extract_commit_sha(self):
195
+ """Test commit SHA extraction."""
196
+ with patch.dict(os.environ, {"CI_COMMIT_SHA": "gitlab123"}):
197
+ provider = GitLabCIProvider()
198
+ commit_sha = provider._extract_commit_sha()
199
+ assert commit_sha == "gitlab123"
200
+
201
+ def test_extract_base_branch(self):
202
+ """Test base branch extraction."""
203
+ with patch.dict(os.environ, {"CI_MERGE_REQUEST_TARGET_BRANCH_NAME": "master"}):
204
+ provider = GitLabCIProvider()
205
+ base_branch = provider._extract_base_branch()
206
+ assert base_branch == "master"
207
+
208
+ def test_extract_source_branch(self):
209
+ """Test source branch extraction."""
210
+ with patch.dict(os.environ, {"CI_MERGE_REQUEST_SOURCE_BRANCH_NAME": "feature"}):
211
+ provider = GitLabCIProvider()
212
+ source_branch = provider._extract_source_branch()
213
+ assert source_branch == "feature"
214
+
215
+ def test_extract_ci_info_mr_context(self):
216
+ """Test full CI info extraction in MR context."""
217
+ with patch.dict(
218
+ os.environ,
219
+ {
220
+ "GITLAB_CI": "true",
221
+ "CI_MERGE_REQUEST_IID": "101",
222
+ "CI_COMMIT_SHA": "gitlab456",
223
+ "CI_MERGE_REQUEST_TARGET_BRANCH_NAME": "main",
224
+ "CI_MERGE_REQUEST_SOURCE_BRANCH_NAME": "feature-x",
225
+ "CI_PROJECT_PATH": "group/project",
226
+ "CI_SERVER_URL": "https://gitlab.com",
227
+ },
228
+ ):
229
+ provider = GitLabCIProvider()
230
+ ci_info = provider.extract_ci_info()
231
+
232
+ assert ci_info.platform == "gitlab-ci"
233
+ assert ci_info.cr_number == 101
234
+ assert ci_info.cr_url == "https://gitlab.com/group/project/-/merge_requests/101"
235
+ assert ci_info.session_type == "cr"
236
+ assert ci_info.commit_sha == "gitlab456"
237
+ assert ci_info.base_branch == "main"
238
+ assert ci_info.source_branch == "feature-x"
239
+ assert ci_info.repository == "group/project"
240
+
241
+ def test_extract_ci_info_mr_context_self_hosted(self):
242
+ """Test MR URL construction for self-hosted GitLab."""
243
+ with patch.dict(
244
+ os.environ,
245
+ {
246
+ "GITLAB_CI": "true",
247
+ "CI_MERGE_REQUEST_IID": "42",
248
+ "CI_PROJECT_PATH": "mycompany/myproject",
249
+ "CI_SERVER_URL": "https://gitlab.mycompany.com",
250
+ },
251
+ clear=True,
252
+ ):
253
+ provider = GitLabCIProvider()
254
+ ci_info = provider.extract_ci_info()
255
+
256
+ assert ci_info.cr_url == "https://gitlab.mycompany.com/mycompany/myproject/-/merge_requests/42"
257
+
258
+ def test_extract_access_token(self):
259
+ """Test CI_JOB_TOKEN detection."""
260
+ with patch.dict(
261
+ os.environ,
262
+ {"GITLAB_CI": "true", "CI_JOB_TOKEN": "glpat-test123token456", "CI_COMMIT_SHA": "gitlab123"},
263
+ clear=True,
264
+ ):
265
+ provider = GitLabCIProvider()
266
+ ci_info = provider.extract_ci_info()
267
+
268
+ assert ci_info.access_token == "glpat-test123token456"
269
+
270
+ def test_extract_access_token_not_set(self):
271
+ """Test when CI_JOB_TOKEN is not set."""
272
+ with patch.dict(os.environ, {"GITLAB_CI": "true", "CI_COMMIT_SHA": "gitlab123"}, clear=True):
273
+ provider = GitLabCIProvider()
274
+ ci_info = provider.extract_ci_info()
275
+
276
+ assert ci_info.access_token is None
277
+
278
+
279
+ class TestCIDetector:
280
+ """Tests for CI detector."""
281
+
282
+ def test_detect_github_actions(self):
283
+ """Test detection of GitHub Actions."""
284
+ with patch.dict(os.environ, {"GITHUB_ACTIONS": "true", "GITHUB_SHA": "test123"}, clear=True):
285
+ ci_info = CIDetector.detect()
286
+ assert ci_info.platform == "github-actions"
287
+
288
+ def test_detect_gitlab_ci(self):
289
+ """Test detection of GitLab CI."""
290
+ with patch.dict(os.environ, {"GITLAB_CI": "true", "CI_COMMIT_SHA": "test456"}, clear=True):
291
+ ci_info = CIDetector.detect()
292
+ assert ci_info.platform == "gitlab-ci"
293
+
294
+ def test_detect_fallback(self):
295
+ """Test fallback detection when no CI platform detected."""
296
+ with patch.dict(os.environ, {}, clear=True):
297
+ with patch.object(CIDetector, "_fallback_detection") as mock_fallback:
298
+ mock_fallback.return_value = CIInfo(
299
+ platform=None, session_type="dev", commit_sha="fallback123", base_branch="main"
300
+ )
301
+ ci_info = CIDetector.detect()
302
+ assert ci_info.platform is None
303
+ assert ci_info.session_type == "dev"
304
+ mock_fallback.assert_called_once()
305
+
306
+ def test_apply_overrides_cr_github(self):
307
+ """Test applying CR override for GitHub Actions."""
308
+ ci_info = CIInfo(platform="github-actions", cr_number=100, session_type="cr", repository="owner/repo")
309
+ ci_info = CIDetector.apply_overrides(ci_info, cr=200)
310
+
311
+ assert ci_info.cr_number == 200
312
+ assert ci_info.cr_url == "https://github.com/owner/repo/pull/200"
313
+ assert ci_info.session_type == "cr"
314
+
315
+ def test_apply_overrides_cr_gitlab(self):
316
+ """Test applying CR override for GitLab CI."""
317
+ with patch.dict(os.environ, {"CI_SERVER_URL": "https://gitlab.com"}):
318
+ ci_info = CIInfo(platform="gitlab-ci", cr_number=50, session_type="cr", repository="group/project")
319
+ ci_info = CIDetector.apply_overrides(ci_info, cr=75)
320
+
321
+ assert ci_info.cr_number == 75
322
+ assert ci_info.cr_url == "https://gitlab.com/group/project/-/merge_requests/75"
323
+ assert ci_info.session_type == "cr"
324
+
325
+ def test_apply_overrides_session_type(self):
326
+ """Test applying session type override."""
327
+ ci_info = CIInfo(session_type="dev")
328
+ ci_info = CIDetector.apply_overrides(ci_info, session_type="prod")
329
+
330
+ assert ci_info.session_type == "prod"
331
+
332
+ def test_apply_overrides_cr_redetermines_session_type(self):
333
+ """Test that CR override re-determines session type."""
334
+ ci_info = CIInfo(cr_number=None, session_type="dev", source_branch="feature")
335
+ ci_info = CIDetector.apply_overrides(ci_info, cr=100)
336
+
337
+ assert ci_info.cr_number == 100
338
+ assert ci_info.session_type == "cr"
339
+
340
+ def test_fallback_detection_with_git(self):
341
+ """Test fallback detection using git commands."""
342
+ with patch.dict(os.environ, {}, clear=True):
343
+ with patch("recce_cloud.ci_providers.base.BaseCIProvider.run_git_command") as mock_git:
344
+ mock_git.side_effect = ["commit123", "feature-branch"]
345
+ ci_info = CIDetector._fallback_detection()
346
+
347
+ assert ci_info.platform is None
348
+ assert ci_info.commit_sha == "commit123"
349
+ assert ci_info.source_branch == "feature-branch"
350
+ assert ci_info.base_branch == "main"
351
+ assert ci_info.session_type == "dev"