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
recce/state/cloud.py ADDED
@@ -0,0 +1,632 @@
1
+ import logging
2
+ import os
3
+ from base64 import b64encode
4
+ from hashlib import md5, sha256
5
+ from typing import Dict, Optional, Tuple, Union
6
+ from urllib.parse import urlencode
7
+
8
+ from recce.exceptions import RecceException
9
+ from recce.pull_request import PullRequestInfo, fetch_pr_metadata
10
+ from recce.util.io import SupportedFileTypes, file_io_factory
11
+ from recce.util.recce_cloud import PresignedUrlMethod, RecceCloud, RecceCloudException
12
+
13
+ from ..event import get_recce_api_token
14
+ from ..models import CheckDAO
15
+ from .const import (
16
+ RECCE_API_TOKEN_MISSING,
17
+ RECCE_CLOUD_PASSWORD_MISSING,
18
+ RECCE_CLOUD_TOKEN_MISSING,
19
+ RECCE_STATE_COMPRESSED_FILE,
20
+ )
21
+ from .state import RecceState
22
+ from .state_loader import RecceStateLoader
23
+
24
+ logger = logging.getLogger("uvicorn")
25
+
26
+
27
+ def s3_sse_c_headers(password: str) -> Dict[str, str]:
28
+ hashed_password = sha256()
29
+ md5_hash = md5()
30
+ hashed_password.update(password.encode())
31
+ md5_hash.update(hashed_password.digest())
32
+ encoded_passwd = b64encode(hashed_password.digest()).decode("utf-8")
33
+ encoded_md5 = b64encode(md5_hash.digest()).decode("utf-8")
34
+ return {
35
+ "x-amz-server-side-encryption-customer-algorithm": "AES256",
36
+ "x-amz-server-side-encryption-customer-key": encoded_passwd,
37
+ "x-amz-server-side-encryption-customer-key-MD5": encoded_md5,
38
+ }
39
+
40
+
41
+ class CloudStateLoader(RecceStateLoader):
42
+ def __init__(
43
+ self,
44
+ review_mode: bool = False,
45
+ cloud_options: Optional[Dict[str, str]] = None,
46
+ initial_state: Optional[RecceState] = None,
47
+ ):
48
+ super().__init__(
49
+ cloud_mode=True,
50
+ review_mode=review_mode,
51
+ cloud_options=cloud_options,
52
+ initial_state=initial_state,
53
+ )
54
+ self.recce_cloud = RecceCloud(token=self.token)
55
+
56
+ def verify(self) -> bool:
57
+ if self.catalog == "github":
58
+ if self.cloud_options.get("github_token") is None:
59
+ self.error_message = RECCE_CLOUD_TOKEN_MISSING.error_message
60
+ self.hint_message = RECCE_CLOUD_TOKEN_MISSING.hint_message
61
+ return False
62
+ if not self.cloud_options.get("host"):
63
+ if self.cloud_options.get("password") is None:
64
+ self.error_message = RECCE_CLOUD_PASSWORD_MISSING.error_message
65
+ self.hint_message = RECCE_CLOUD_PASSWORD_MISSING.hint_message
66
+ return False
67
+ elif self.catalog == "preview":
68
+ if self.cloud_options.get("api_token") is None:
69
+ self.error_message = RECCE_API_TOKEN_MISSING.error_message
70
+ self.hint_message = RECCE_API_TOKEN_MISSING.hint_message
71
+ return False
72
+ if self.cloud_options.get("share_id") is None:
73
+ self.error_message = "No share ID is provided for the preview catalog."
74
+ self.hint_message = (
75
+ 'Please provide a share URL in the command argument with option "--share-url <share-url>"'
76
+ )
77
+ return False
78
+ elif self.catalog == "session":
79
+ if self.cloud_options.get("api_token") is None:
80
+ self.error_message = RECCE_API_TOKEN_MISSING.error_message
81
+ self.hint_message = RECCE_API_TOKEN_MISSING.hint_message
82
+ return False
83
+ if self.cloud_options.get("session_id") is None:
84
+ self.error_message = "No session ID is provided for the session catalog."
85
+ self.hint_message = (
86
+ 'Please provide a session ID in the command argument with option "--session-id <session-id>"'
87
+ )
88
+ return False
89
+ return True
90
+
91
+ def purge(self) -> bool:
92
+ rc, err_msg = RecceCloudStateManager(self.cloud_options).purge_cloud_state()
93
+ if err_msg:
94
+ self.error_message = err_msg
95
+ return rc
96
+
97
+ def _load_state(self) -> Tuple[RecceState, str]:
98
+ """
99
+ Load the state from Recce Cloud based on catalog type.
100
+
101
+ Returns:
102
+ RecceState: The state object.
103
+ str: The etag of the state file (only used for GitHub).
104
+ """
105
+ if self.catalog == "github":
106
+ return self._load_state_from_github()
107
+ elif self.catalog == "preview":
108
+ return self._load_state_from_preview()
109
+ elif self.catalog == "session":
110
+ return self._load_state_from_session(), None
111
+ else:
112
+ raise RecceException(f"Unsupported catalog type: {self.catalog}")
113
+
114
+ def _load_state_from_github(self) -> Tuple[RecceState, str]:
115
+ """Load state from GitHub PR with etag checking."""
116
+ if (self.pr_info is None) or (self.pr_info.id is None) or (self.pr_info.repository is None):
117
+ raise RecceException("Cannot get the pull request information from GitHub.")
118
+
119
+ logger.debug("Fetching GitHub state from Recce Cloud...")
120
+
121
+ # Check metadata and etag for GitHub only
122
+ metadata = self._get_metadata_from_recce_cloud()
123
+ state_etag = metadata.get("etag") if metadata else None
124
+
125
+ # Return cached state if etag matches
126
+ if self.state_etag and state_etag == self.state_etag:
127
+ return self.state, self.state_etag
128
+
129
+ # Download state from GitHub
130
+ presigned_url = self.recce_cloud.get_presigned_url_by_github_repo(
131
+ method=PresignedUrlMethod.DOWNLOAD,
132
+ pr_id=self.pr_info.id,
133
+ repository=self.pr_info.repository,
134
+ artifact_name=RECCE_STATE_COMPRESSED_FILE,
135
+ )
136
+
137
+ password = self.cloud_options.get("password")
138
+ if password is None:
139
+ raise RecceException(RECCE_CLOUD_PASSWORD_MISSING.error_message)
140
+
141
+ headers = s3_sse_c_headers(password)
142
+ loaded_state = self._download_state_from_url(presigned_url, SupportedFileTypes.GZIP, headers)
143
+
144
+ # Handle the case where download returns None (404 error)
145
+ if loaded_state is None:
146
+ return None, state_etag
147
+
148
+ return loaded_state, state_etag
149
+
150
+ def _load_state_from_preview(self) -> Tuple[RecceState, None]:
151
+ """Load state from preview share (no etag checking needed)."""
152
+ if self.share_id is None:
153
+ raise RecceException("Cannot load the share state from Recce Cloud. No share ID is provided.")
154
+
155
+ logger.debug("Fetching preview state from Recce Cloud...")
156
+
157
+ # Download state from preview share
158
+ presigned_url = self.recce_cloud.get_presigned_url_by_share_id(
159
+ method=PresignedUrlMethod.DOWNLOAD, share_id=self.share_id
160
+ )
161
+
162
+ loaded_state = self._download_state_from_url(presigned_url, SupportedFileTypes.FILE)
163
+
164
+ # Handle the case where download returns None (404 error)
165
+ if loaded_state is None:
166
+ return None, None
167
+
168
+ return loaded_state, None
169
+
170
+ def _get_metadata_from_recce_cloud(self) -> Union[dict, None]:
171
+ return self.recce_cloud.get_artifact_metadata(pr_info=self.pr_info) if self.pr_info else None
172
+
173
+ def _download_state_from_url(
174
+ self, presigned_url: str, file_type: SupportedFileTypes, headers: dict = None
175
+ ) -> RecceState:
176
+ """Download state file from presigned URL and convert to RecceState."""
177
+ import tempfile
178
+
179
+ import requests
180
+
181
+ with tempfile.NamedTemporaryFile() as tmp:
182
+ response = requests.get(presigned_url, headers=headers)
183
+
184
+ if response.status_code == 404:
185
+ self.error_message = "The state file is not found in Recce Cloud."
186
+ return None
187
+ elif response.status_code != 200:
188
+ self.error_message = response.text
189
+ error_msg = f"{response.status_code} Failed to download the state file from Recce Cloud."
190
+ if headers: # GitHub case with password
191
+ error_msg += " The password could be wrong."
192
+ raise RecceException(error_msg)
193
+
194
+ with open(tmp.name, "wb") as f:
195
+ f.write(response.content)
196
+
197
+ return RecceState.from_file(tmp.name, file_type=file_type)
198
+
199
+ def _load_state_from_session(self) -> RecceState:
200
+ """
201
+ Load state from session by:
202
+ 1. Get session info
203
+ 2. Download artifacts for both base and current sessions
204
+ 3. Download recce_state if available, otherwise create empty state with artifacts
205
+ """
206
+ if self.session_id is None:
207
+ raise RecceException("Cannot load the session state from Recce Cloud. No session ID is provided.")
208
+
209
+ # 1. Get session information
210
+ logger.debug(f"Getting session {self.session_id}")
211
+ session = self.recce_cloud.get_session(self.session_id)
212
+
213
+ pr_url = session.get("pr_link")
214
+ org_id = session.get("org_id")
215
+ project_id = session.get("project_id")
216
+
217
+ if not org_id or not project_id:
218
+ raise RecceException(f"Session {self.session_id} does not belong to a valid organization or project.")
219
+
220
+ # 2. Download manifests and catalogs for both session
221
+ logger.debug(f"Downloading current session artifacts for {self.session_id}")
222
+ current_artifacts = self._download_session_artifacts(self.recce_cloud, org_id, project_id, self.session_id)
223
+
224
+ logger.debug(f"Downloading base session artifacts for project {project_id}")
225
+ base_artifacts = self._download_base_session_artifacts(self.recce_cloud, org_id, project_id)
226
+
227
+ # 3. Try to download existing recce_state, otherwise create new state
228
+ try:
229
+ logger.debug(f"Downloading recce_state for session {self.session_id}")
230
+ state = self._download_session_recce_state(self.recce_cloud, org_id, project_id, self.session_id)
231
+ except Exception as e:
232
+ logger.debug(f"No existing recce_state found, creating new state: {e}")
233
+ state = RecceState()
234
+
235
+ if pr_url:
236
+ pr_id = pr_url.rstrip("/").split("/")[-1]
237
+ pull_request = PullRequestInfo(id=pr_id, url=pr_url)
238
+ self.pr_info = pull_request
239
+ if state.pull_request is None:
240
+ state.pull_request = pull_request
241
+
242
+ # Set artifacts regardless of whether we loaded existing state
243
+ state.artifacts.base = base_artifacts
244
+ state.artifacts.current = current_artifacts
245
+
246
+ return state
247
+
248
+ def _download_session_artifacts(self, recce_cloud, org_id: str, project_id: str, session_id: str) -> dict:
249
+ """Download manifest and catalog for a session, return JSON data directly."""
250
+ import requests
251
+
252
+ # Get download URLs
253
+ presigned_urls = recce_cloud.get_download_urls_by_session_id(org_id, project_id, session_id)
254
+
255
+ artifacts = {}
256
+
257
+ # Download manifest
258
+ response = requests.get(presigned_urls["manifest_url"])
259
+ if response.status_code == 200:
260
+ artifacts["manifest"] = response.json()
261
+ else:
262
+ raise RecceException(f"Failed to download manifest for session {session_id}")
263
+
264
+ # Download catalog
265
+ response = requests.get(presigned_urls["catalog_url"])
266
+ if response.status_code == 200:
267
+ artifacts["catalog"] = response.json()
268
+ else:
269
+ raise RecceException(f"Failed to download catalog for session {session_id}")
270
+
271
+ return artifacts
272
+
273
+ def _download_session_recce_state(self, recce_cloud, org_id: str, project_id: str, session_id: str) -> RecceState:
274
+ """Download recce_state for a session."""
275
+ # Get download URLs (now includes recce_state_url)
276
+ presigned_urls = recce_cloud.get_download_urls_by_session_id(org_id, project_id, session_id)
277
+ recce_state_url = presigned_urls.get("recce_state_url")
278
+
279
+ if not recce_state_url:
280
+ raise RecceException(f"No recce_state_url found for session {session_id}")
281
+
282
+ # Reuse the existing download method
283
+ state = self._download_state_from_url(recce_state_url, SupportedFileTypes.FILE)
284
+
285
+ if state is None:
286
+ raise RecceException(f"Failed to download recce_state for session {session_id}")
287
+
288
+ return state
289
+
290
+ def _download_base_session_artifacts(self, recce_cloud, org_id: str, project_id: str) -> dict:
291
+ """Download manifest and catalog for the base session, return JSON data directly."""
292
+ import requests
293
+
294
+ # Get download URLs for base session
295
+ presigned_urls = recce_cloud.get_base_session_download_urls(org_id, project_id)
296
+
297
+ artifacts = {}
298
+
299
+ # Download manifest
300
+ response = requests.get(presigned_urls["manifest_url"])
301
+ if response.status_code == 200:
302
+ artifacts["manifest"] = response.json()
303
+ else:
304
+ raise RecceException(f"Failed to download base session manifest for project {project_id}")
305
+
306
+ # Download catalog
307
+ response = requests.get(presigned_urls["catalog_url"])
308
+ if response.status_code == 200:
309
+ artifacts["catalog"] = response.json()
310
+ else:
311
+ raise RecceException(f"Failed to download base session catalog for project {project_id}")
312
+
313
+ return artifacts
314
+
315
+ def _export_state(self) -> Tuple[Union[str, None], str]:
316
+ """
317
+ Export state to Recce Cloud based on catalog type.
318
+
319
+ Returns:
320
+ str: A message indicating the result of the export operation.
321
+ str: The etag of the exported state file (only used for GitHub).
322
+ """
323
+ logger.info("Store recce state to Recce Cloud")
324
+
325
+ if self.catalog == "github":
326
+ return self._export_state_to_github()
327
+ elif self.catalog == "preview":
328
+ return self._export_state_to_preview()
329
+ elif self.catalog == "session":
330
+ return self._export_state_to_session()
331
+ else:
332
+ raise RecceException(f"Unsupported catalog type: {self.catalog}")
333
+
334
+ def _export_state_to_github(self) -> Tuple[Union[str, None], str]:
335
+ """Export state to GitHub PR with metadata and etag."""
336
+ if (self.pr_info is None) or (self.pr_info.id is None) or (self.pr_info.repository is None):
337
+ raise RecceException("Cannot get the pull request information from GitHub.")
338
+
339
+ # Generate metadata for GitHub only
340
+ check_status = CheckDAO().status()
341
+ metadata = {
342
+ "total_checks": check_status.get("total", 0),
343
+ "approved_checks": check_status.get("approved", 0),
344
+ }
345
+
346
+ # Upload to Cloud
347
+ presigned_url = self.recce_cloud.get_presigned_url_by_github_repo(
348
+ method=PresignedUrlMethod.UPLOAD,
349
+ repository=self.pr_info.repository,
350
+ artifact_name=RECCE_STATE_COMPRESSED_FILE,
351
+ pr_id=self.pr_info.id,
352
+ metadata=metadata,
353
+ )
354
+ message = self._upload_state_to_url(
355
+ presigned_url=presigned_url,
356
+ file_type=SupportedFileTypes.GZIP,
357
+ password=self.cloud_options.get("password"),
358
+ metadata=metadata,
359
+ )
360
+
361
+ # Get updated etag after upload
362
+ metadata_response = self._get_metadata_from_recce_cloud()
363
+ state_etag = metadata_response.get("etag") if metadata_response else None
364
+
365
+ if message:
366
+ logger.warning(message)
367
+ return message, state_etag
368
+
369
+ def _export_state_to_preview(self) -> Tuple[Union[str, None], None]:
370
+ """Export state to preview share (no metadata or etag needed)."""
371
+ share_id = self.cloud_options.get("share_id")
372
+ presigned_url = self.recce_cloud.get_presigned_url_by_share_id(
373
+ method=PresignedUrlMethod.UPLOAD,
374
+ share_id=share_id,
375
+ metadata=None,
376
+ )
377
+ message = self._upload_state_to_url(
378
+ presigned_url=presigned_url, file_type=SupportedFileTypes.FILE, password=None, metadata=None
379
+ )
380
+
381
+ if message:
382
+ logger.warning(message)
383
+ return message, None
384
+
385
+ def _export_state_to_session(self) -> Tuple[Union[str, None], None]:
386
+ """Export state to session (upload recce_state with empty artifacts)."""
387
+ if self.session_id is None:
388
+ raise RecceException("Cannot export state to session. No session ID is provided.")
389
+
390
+ # Get session information
391
+ session = self.recce_cloud.get_session(self.session_id)
392
+ org_id = session.get("org_id")
393
+ project_id = session.get("project_id")
394
+
395
+ if not org_id or not project_id:
396
+ raise RecceException(f"Session {self.session_id} does not belong to a valid organization or project.")
397
+
398
+ # Get upload URLs (now includes recce_state_url)
399
+ presigned_urls = self.recce_cloud.get_upload_urls_by_session_id(org_id, project_id, self.session_id)
400
+ recce_state_url = presigned_urls.get("recce_state_url")
401
+
402
+ if not recce_state_url:
403
+ raise RecceException(f"No recce_state_url found for session {self.session_id}")
404
+
405
+ # Create a copy of the state with empty artifacts for upload
406
+ upload_state = RecceState()
407
+ upload_state.runs = self.state.runs.copy() if self.state.runs else []
408
+ upload_state.checks = self.state.checks.copy() if self.state.checks else []
409
+ # Keep artifacts empty (don't copy self.state.artifacts)
410
+
411
+ # Upload the state with empty artifacts
412
+ message = self._upload_state_to_url(
413
+ presigned_url=recce_state_url,
414
+ file_type=SupportedFileTypes.FILE,
415
+ password=None,
416
+ metadata=None,
417
+ state=upload_state,
418
+ )
419
+
420
+ if message:
421
+ logger.warning(message)
422
+ else:
423
+ # Notify Recce Cloud that the state has been uploaded if upload is successful
424
+ self.recce_cloud.post_recce_state_uploaded_by_session_id(org_id, project_id, self.session_id)
425
+ return message, None
426
+
427
+ def _upload_state_to_url(
428
+ self,
429
+ presigned_url: str,
430
+ file_type: SupportedFileTypes,
431
+ password: str = None,
432
+ metadata: dict = None,
433
+ state: RecceState = None,
434
+ ) -> Union[str, None]:
435
+ """Upload state file to presigned URL."""
436
+ import tempfile
437
+
438
+ import requests
439
+
440
+ # Use provided state or default to self.state
441
+ upload_state = state or self.state
442
+
443
+ # Prepare headers
444
+ headers = {}
445
+ if password:
446
+ headers.update(s3_sse_c_headers(password))
447
+ if metadata:
448
+ headers["x-amz-tagging"] = urlencode(metadata)
449
+
450
+ with tempfile.NamedTemporaryFile() as tmp:
451
+ # Use the specified state to export to file
452
+ json_data = upload_state.to_json()
453
+ io = file_io_factory(file_type)
454
+ io.write(tmp.name, json_data)
455
+
456
+ with open(tmp.name, "rb") as fd:
457
+ response = requests.put(presigned_url, data=fd.read(), headers=headers)
458
+
459
+ if response.status_code not in [200, 204]:
460
+ self.error_message = response.text
461
+ return "Failed to upload the state file to Recce Cloud. Reason: " + response.text
462
+
463
+ return None
464
+
465
+ def check_conflict(self) -> bool:
466
+ if self.catalog != "github":
467
+ return False
468
+
469
+ metadata = self._get_metadata_from_recce_cloud()
470
+ if not metadata:
471
+ return False
472
+
473
+ state_etag = metadata.get("etag")
474
+ return state_etag != self.state_etag
475
+
476
+
477
+ class RecceCloudStateManager:
478
+ error_message: str
479
+ hint_message: str
480
+
481
+ # It is a class to upload, download and purge the state file on Recce Cloud.
482
+
483
+ def __init__(self, cloud_options: Optional[Dict[str, str]] = None):
484
+ self.cloud_options = cloud_options or {}
485
+ self.pr_info = None
486
+ self.error_message = None
487
+ self.hint_message = None
488
+ self.github_token = self.cloud_options.get("github_token")
489
+
490
+ if not self.github_token:
491
+ raise RecceException(RECCE_CLOUD_TOKEN_MISSING.error_message)
492
+ self.pr_info = fetch_pr_metadata(cloud=True, github_token=self.github_token)
493
+ if self.pr_info.id is None:
494
+ raise RecceException("Cannot get the pull request information from GitHub.")
495
+
496
+ def verify(self) -> bool:
497
+ if self.github_token is None:
498
+ self.error_message = RECCE_CLOUD_TOKEN_MISSING.error_message
499
+ self.hint_message = RECCE_CLOUD_TOKEN_MISSING.hint_message
500
+ return False
501
+ if self.cloud_options.get("password") is None:
502
+ self.error_message = RECCE_CLOUD_PASSWORD_MISSING.error_message
503
+ self.hint_message = RECCE_CLOUD_PASSWORD_MISSING.hint_message
504
+ return False
505
+ return True
506
+
507
+ @property
508
+ def error_and_hint(self) -> (Union[str, None], Union[str, None]):
509
+ return self.error_message, self.hint_message
510
+
511
+ def _check_state_in_recce_cloud(self) -> bool:
512
+ return RecceCloud(token=self.github_token).check_artifacts_exists(self.pr_info)
513
+
514
+ def check_cloud_state_exists(self) -> bool:
515
+ return self._check_state_in_recce_cloud()
516
+
517
+ def _upload_state_to_recce_cloud(self, state: RecceState, metadata: dict = None) -> Union[str, None]:
518
+ import tempfile
519
+
520
+ import requests
521
+
522
+ presigned_url = RecceCloud(token=self.github_token).get_presigned_url_by_github_repo(
523
+ method=PresignedUrlMethod.UPLOAD,
524
+ repository=self.pr_info.repository,
525
+ artifact_name=RECCE_STATE_COMPRESSED_FILE,
526
+ pr_id=self.pr_info.id,
527
+ metadata=metadata,
528
+ )
529
+
530
+ compress_passwd = self.cloud_options.get("password")
531
+ headers = s3_sse_c_headers(compress_passwd)
532
+ with tempfile.NamedTemporaryFile() as tmp:
533
+ state.to_file(tmp.name, file_type=SupportedFileTypes.GZIP)
534
+ response = requests.put(presigned_url, data=open(tmp.name, "rb").read(), headers=headers)
535
+ if response.status_code != 200:
536
+ return f"Failed to upload the state file to Recce Cloud. Reason: {response.text}"
537
+ return "The state file is uploaded to Recce Cloud."
538
+
539
+ def upload_state_to_cloud(self, state: RecceState) -> Union[str, None]:
540
+ if (self.pr_info is None) or (self.pr_info.id is None) or (self.pr_info.repository is None):
541
+ raise RecceException("Cannot get the pull request information from GitHub.")
542
+
543
+ checks = state.checks
544
+
545
+ metadata = {
546
+ "total_checks": len(checks),
547
+ "approved_checks": len([c for c in checks if c.is_checked]),
548
+ }
549
+
550
+ return self._upload_state_to_recce_cloud(state, metadata)
551
+
552
+ def _download_state_from_recce_cloud(self, filepath):
553
+ import io
554
+
555
+ import requests
556
+
557
+ presigned_url = RecceCloud(token=self.github_token).get_presigned_url_by_github_repo(
558
+ method=PresignedUrlMethod.DOWNLOAD,
559
+ repository=self.pr_info.repository,
560
+ artifact_name=RECCE_STATE_COMPRESSED_FILE,
561
+ pr_id=self.pr_info.id,
562
+ )
563
+
564
+ password = self.cloud_options.get("password")
565
+ if password is None:
566
+ raise RecceException(RECCE_CLOUD_PASSWORD_MISSING.error_message)
567
+
568
+ headers = s3_sse_c_headers(password)
569
+ response = requests.get(presigned_url, headers=headers)
570
+
571
+ if response.status_code != 200:
572
+ raise RecceException(
573
+ f"{response.status_code} Failed to download the state file from Recce Cloud. The password could be wrong."
574
+ )
575
+
576
+ byte_stream = io.BytesIO(response.content)
577
+ gzip_io = file_io_factory(SupportedFileTypes.GZIP)
578
+ decompressed_content = gzip_io.read_fileobj(byte_stream)
579
+
580
+ dirs = os.path.dirname(filepath)
581
+ if dirs:
582
+ os.makedirs(dirs, exist_ok=True)
583
+ with open(filepath, "wb") as f:
584
+ f.write(decompressed_content)
585
+
586
+ def download_state_from_cloud(self, filepath: str) -> Union[str, None]:
587
+ if (self.pr_info is None) or (self.pr_info.id is None) or (self.pr_info.repository is None):
588
+ raise RecceException("Cannot get the pull request information from GitHub.")
589
+
590
+ logger.debug("Download state file from Recce Cloud...")
591
+ return self._download_state_from_recce_cloud(filepath)
592
+
593
+ def _purge_state_from_recce_cloud(self) -> (bool, str):
594
+ try:
595
+ RecceCloud(token=self.github_token).purge_artifacts(self.pr_info.repository, pr_id=self.pr_info.id)
596
+ except RecceCloudException as e:
597
+ return False, e.reason
598
+ return True, None
599
+
600
+ def purge_cloud_state(self) -> (bool, str):
601
+ return self._purge_state_from_recce_cloud()
602
+
603
+
604
+ class RecceShareStateManager:
605
+ error_message: str
606
+ hint_message: str
607
+
608
+ # It is a class to share state file on Recce Cloud.
609
+
610
+ def __init__(self, auth_options: Optional[Dict[str, str]] = None):
611
+ self.auth_options = auth_options or {}
612
+ self.error_message = None
613
+ self.hint_message = None
614
+
615
+ def verify(self) -> bool:
616
+ if get_recce_api_token() is None:
617
+ self.error_message = RECCE_API_TOKEN_MISSING.error_message
618
+ self.hint_message = RECCE_API_TOKEN_MISSING.hint_message
619
+ return False
620
+ return True
621
+
622
+ @property
623
+ def error_and_hint(self) -> (Union[str, None], Union[str, None]):
624
+ return self.error_message, self.hint_message
625
+
626
+ def share_state(self, file_name: str, state: RecceState) -> Dict:
627
+ import tempfile
628
+
629
+ with tempfile.NamedTemporaryFile() as tmp:
630
+ state.to_file(tmp.name, file_type=SupportedFileTypes.FILE)
631
+ response = RecceCloud(token=get_recce_api_token()).share_state(file_name, open(tmp.name, "rb"))
632
+ return response
recce/state/const.py ADDED
@@ -0,0 +1,26 @@
1
+ from dataclasses import dataclass
2
+
3
+ RECCE_STATE_FILE = "recce-state.json"
4
+ RECCE_STATE_COMPRESSED_FILE = f"{RECCE_STATE_FILE}.gz"
5
+
6
+
7
+ @dataclass
8
+ class ErrorMessage:
9
+ error_message: str
10
+ hint_message: str
11
+
12
+
13
+ RECCE_CLOUD_TOKEN_MISSING = ErrorMessage(
14
+ error_message="No GitHub token is provided to access the pull request information",
15
+ hint_message="Please provide a GitHub token in the command argument",
16
+ )
17
+
18
+ RECCE_CLOUD_PASSWORD_MISSING = ErrorMessage(
19
+ error_message="No password provided to access the state file in Recce Cloud",
20
+ hint_message='Please provide a password with the option "--password <compress-password>"',
21
+ )
22
+
23
+ RECCE_API_TOKEN_MISSING = ErrorMessage(
24
+ error_message="No Recce API token is provided",
25
+ hint_message="Please login to Recce Cloud and copy the API token from the settings page",
26
+ )