sunholo 0.59.1__py3-none-any.whl → 0.59.3__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.
- sunholo/bots/github_webhook.py +264 -0
- sunholo/cli/configs.py +6 -10
- sunholo/utils/config_schema.py +3 -2
- {sunholo-0.59.1.dist-info → sunholo-0.59.3.dist-info}/METADATA +2 -2
- {sunholo-0.59.1.dist-info → sunholo-0.59.3.dist-info}/RECORD +9 -8
- {sunholo-0.59.1.dist-info → sunholo-0.59.3.dist-info}/LICENSE.txt +0 -0
- {sunholo-0.59.1.dist-info → sunholo-0.59.3.dist-info}/WHEEL +0 -0
- {sunholo-0.59.1.dist-info → sunholo-0.59.3.dist-info}/entry_points.txt +0 -0
- {sunholo-0.59.1.dist-info → sunholo-0.59.3.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
# from https://github.com/ray-project/docu-mentor
|
|
2
|
+
import base64
|
|
3
|
+
import httpx
|
|
4
|
+
from dotenv import load_dotenv
|
|
5
|
+
import jwt
|
|
6
|
+
import os
|
|
7
|
+
import time
|
|
8
|
+
|
|
9
|
+
load_dotenv()
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
APP_ID = os.environ.get("APP_ID")
|
|
14
|
+
PRIVATE_KEY = os.environ.get("PRIVATE_KEY", "")
|
|
15
|
+
|
|
16
|
+
# with open('private-key.pem', 'r') as f:
|
|
17
|
+
# PRIVATE_KEY = f.read()
|
|
18
|
+
|
|
19
|
+
def generate_jwt():
|
|
20
|
+
payload = {
|
|
21
|
+
"iat": int(time.time()),
|
|
22
|
+
"exp": int(time.time()) + (10 * 60),
|
|
23
|
+
"iss": APP_ID,
|
|
24
|
+
}
|
|
25
|
+
if PRIVATE_KEY:
|
|
26
|
+
jwt_token = jwt.encode(payload, PRIVATE_KEY, algorithm="RS256")
|
|
27
|
+
return jwt_token
|
|
28
|
+
raise ValueError("PRIVATE_KEY not found.")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
async def get_installation_access_token(jwt, installation_id):
|
|
32
|
+
url = f"https://api.github.com/app/installations/{installation_id}/access_tokens"
|
|
33
|
+
headers = {
|
|
34
|
+
"Authorization": f"Bearer {jwt}",
|
|
35
|
+
"Accept": "application/vnd.github.v3+json",
|
|
36
|
+
}
|
|
37
|
+
async with httpx.AsyncClient() as client:
|
|
38
|
+
response = await client.post(url, headers=headers)
|
|
39
|
+
return response.json()["token"]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def get_diff_url(pr):
|
|
43
|
+
"""GitHub 302s to this URL."""
|
|
44
|
+
original_url = pr.get("url")
|
|
45
|
+
parts = original_url.split("/")
|
|
46
|
+
owner, repo, pr_number = parts[-4], parts[-3], parts[-1]
|
|
47
|
+
return f"https://patch-diff.githubusercontent.com/raw/{owner}/{repo}/pull/{pr_number}.diff"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
async def get_branch_files(pr, branch, headers):
|
|
51
|
+
original_url = pr.get("url")
|
|
52
|
+
parts = original_url.split("/")
|
|
53
|
+
owner, repo = parts[-4], parts[-3]
|
|
54
|
+
url = f"https://api.github.com/repos/{owner}/{repo}/git/trees/{branch}?recursive=1"
|
|
55
|
+
async with httpx.AsyncClient() as client:
|
|
56
|
+
response = await client.get(url, headers=headers)
|
|
57
|
+
tree = response.json().get('tree', [])
|
|
58
|
+
files = {}
|
|
59
|
+
for item in tree:
|
|
60
|
+
if item['type'] == 'blob':
|
|
61
|
+
file_url = item['url']
|
|
62
|
+
print(file_url)
|
|
63
|
+
file_response = await client.get(file_url, headers=headers)
|
|
64
|
+
content = file_response.json().get('content', '')
|
|
65
|
+
# Decode the base64 content
|
|
66
|
+
decoded_content = base64.b64decode(content).decode('utf-8')
|
|
67
|
+
files[item['path']] = decoded_content
|
|
68
|
+
return files
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
async def get_pr_head_branch(pr, headers):
|
|
72
|
+
original_url = pr.get("url")
|
|
73
|
+
parts = original_url.split("/")
|
|
74
|
+
owner, repo, pr_number = parts[-4], parts[-3], parts[-1]
|
|
75
|
+
url = f"https://api.github.com/repos/{owner}/{repo}/pulls/{pr_number}"
|
|
76
|
+
|
|
77
|
+
async with httpx.AsyncClient() as client:
|
|
78
|
+
response = await client.get(url, headers=headers)
|
|
79
|
+
|
|
80
|
+
# Check if the response is successful
|
|
81
|
+
if response.status_code != 200:
|
|
82
|
+
print(f"Error: Received status code {response.status_code}")
|
|
83
|
+
print("Response body:", response.text)
|
|
84
|
+
return ''
|
|
85
|
+
|
|
86
|
+
# Safely get the 'ref'
|
|
87
|
+
data = response.json()
|
|
88
|
+
head_data = data.get('head', {})
|
|
89
|
+
ref = head_data.get('ref', '')
|
|
90
|
+
return ref
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def files_to_diff_dict(diff):
|
|
94
|
+
files_with_diff = {}
|
|
95
|
+
current_file = None
|
|
96
|
+
for line in diff.split("\n"):
|
|
97
|
+
if line.startswith("diff --git"):
|
|
98
|
+
current_file = line.split(" ")[2][2:]
|
|
99
|
+
files_with_diff[current_file] = {"text": []}
|
|
100
|
+
elif line.startswith("+") and not line.startswith("+++"):
|
|
101
|
+
files_with_diff[current_file]["text"].append(line[1:])
|
|
102
|
+
return files_with_diff
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def parse_diff_to_line_numbers(diff):
|
|
106
|
+
files_with_line_numbers = {}
|
|
107
|
+
current_file = None
|
|
108
|
+
line_number = 0
|
|
109
|
+
for line in diff.split("\n"):
|
|
110
|
+
if line.startswith("diff --git"):
|
|
111
|
+
current_file = line.split(" ")[2][2:]
|
|
112
|
+
files_with_line_numbers[current_file] = []
|
|
113
|
+
line_number = 0
|
|
114
|
+
elif line.startswith("@@"):
|
|
115
|
+
line_number = int(line.split(" ")[2].split(",")[0][1:]) - 1
|
|
116
|
+
elif line.startswith("+") and not line.startswith("+++"):
|
|
117
|
+
files_with_line_numbers[current_file].append(line_number)
|
|
118
|
+
line_number += 1
|
|
119
|
+
elif not line.startswith("-"):
|
|
120
|
+
line_number += 1
|
|
121
|
+
return files_with_line_numbers
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def get_context_from_files(files, files_with_line_numbers, context_lines=2):
|
|
125
|
+
context_data = {}
|
|
126
|
+
for file, lines in files_with_line_numbers.items():
|
|
127
|
+
file_content = files[file].split("\n")
|
|
128
|
+
context_data[file] = []
|
|
129
|
+
for line in lines:
|
|
130
|
+
start = max(line - context_lines, 0)
|
|
131
|
+
end = min(line + context_lines + 1, len(file_content))
|
|
132
|
+
context_data[file].append('\n'.join(file_content[start:end]))
|
|
133
|
+
return context_data
|
|
134
|
+
|
|
135
|
+
app = FastAPI()
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
async def handle_webhook(request: Request):
|
|
139
|
+
data = await request.json()
|
|
140
|
+
|
|
141
|
+
installation = data.get("installation")
|
|
142
|
+
if installation and installation.get("id"):
|
|
143
|
+
installation_id = installation.get("id")
|
|
144
|
+
logger.info(f"Installation ID: {installation_id}")
|
|
145
|
+
|
|
146
|
+
JWT_TOKEN = generate_jwt()
|
|
147
|
+
|
|
148
|
+
installation_access_token = await get_installation_access_token(
|
|
149
|
+
JWT_TOKEN, installation_id
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
headers = {
|
|
153
|
+
"Authorization": f"token {installation_access_token}",
|
|
154
|
+
"User-Agent": "docu-mentor-bot",
|
|
155
|
+
"Accept": "application/vnd.github.VERSION.diff",
|
|
156
|
+
}
|
|
157
|
+
else:
|
|
158
|
+
raise ValueError("No app installation found.")
|
|
159
|
+
|
|
160
|
+
# If PR exists and is opened
|
|
161
|
+
if "pull_request" in data.keys() and (
|
|
162
|
+
data["action"] in ["opened", "reopened"]
|
|
163
|
+
): # use "synchronize" for tracking new commits
|
|
164
|
+
pr = data.get("pull_request")
|
|
165
|
+
|
|
166
|
+
# Greet the user and show instructions.
|
|
167
|
+
async with httpx.AsyncClient() as client:
|
|
168
|
+
await client.post(
|
|
169
|
+
f"{pr['issue_url']}/comments",
|
|
170
|
+
json={"body": GREETING},
|
|
171
|
+
headers=headers,
|
|
172
|
+
)
|
|
173
|
+
return JSONResponse(content={}, status_code=200)
|
|
174
|
+
|
|
175
|
+
# Check if the event is a new or modified issue comment
|
|
176
|
+
if "issue" in data.keys() and data.get("action") in ["created", "edited"]:
|
|
177
|
+
issue = data["issue"]
|
|
178
|
+
|
|
179
|
+
# Check if the issue is a pull request
|
|
180
|
+
if "/pull/" in issue["html_url"]:
|
|
181
|
+
pr = issue.get("pull_request")
|
|
182
|
+
|
|
183
|
+
# Get the comment body
|
|
184
|
+
comment = data.get("comment")
|
|
185
|
+
comment_body = comment.get("body")
|
|
186
|
+
# Remove all whitespace characters except for regular spaces
|
|
187
|
+
comment_body = comment_body.translate(
|
|
188
|
+
str.maketrans("", "", string.whitespace.replace(" ", ""))
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
# Skip if the bot talks about itself
|
|
192
|
+
author_handle = comment["user"]["login"]
|
|
193
|
+
|
|
194
|
+
# Check if the bot is mentioned in the comment
|
|
195
|
+
if (
|
|
196
|
+
author_handle != "docu-mentor[bot]"
|
|
197
|
+
and "@docu-mentor run" in comment_body
|
|
198
|
+
):
|
|
199
|
+
async with httpx.AsyncClient() as client:
|
|
200
|
+
# Fetch diff from GitHub
|
|
201
|
+
files_to_keep = comment_body.replace(
|
|
202
|
+
"@docu-mentor run", ""
|
|
203
|
+
).split(" ")
|
|
204
|
+
files_to_keep = [item for item in files_to_keep if item]
|
|
205
|
+
|
|
206
|
+
logger.info(files_to_keep)
|
|
207
|
+
|
|
208
|
+
url = get_diff_url(pr)
|
|
209
|
+
diff_response = await client.get(url, headers=headers)
|
|
210
|
+
diff = diff_response.text
|
|
211
|
+
|
|
212
|
+
files_with_lines = parse_diff_to_line_numbers(diff)
|
|
213
|
+
|
|
214
|
+
# Get head branch of the PR
|
|
215
|
+
headers["Accept"] = "application/vnd.github.full+json"
|
|
216
|
+
head_branch = await get_pr_head_branch(pr, headers)
|
|
217
|
+
|
|
218
|
+
# Get files from head branch
|
|
219
|
+
head_branch_files = await get_branch_files(pr, head_branch, headers)
|
|
220
|
+
print("HEAD FILES", head_branch_files)
|
|
221
|
+
|
|
222
|
+
# Enrich diff data with context from the head branch.
|
|
223
|
+
context_files = get_context_from_files(head_branch_files, files_with_lines)
|
|
224
|
+
|
|
225
|
+
# Filter the dictionary
|
|
226
|
+
if files_to_keep:
|
|
227
|
+
context_files = {
|
|
228
|
+
k: context_files[k]
|
|
229
|
+
for k in context_files
|
|
230
|
+
if any(sub in k for sub in files_to_keep)
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
# Get suggestions from Docu Mentor
|
|
234
|
+
content, model, prompt_tokens, completion_tokens = \
|
|
235
|
+
ray_mentor(context_files) if ray.is_initialized() else mentor(context_files)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
# Let's comment on the PR
|
|
239
|
+
await client.post(
|
|
240
|
+
f"{comment['issue_url']}/comments",
|
|
241
|
+
json={
|
|
242
|
+
"body": f":rocket: Docu Mentor finished "
|
|
243
|
+
+ "analysing your PR! :rocket:\n\n"
|
|
244
|
+
+ "Take a look at your results:\n"
|
|
245
|
+
+ f"{content}\n\n"
|
|
246
|
+
+ "This bot is powered by "
|
|
247
|
+
+ "[Sunholo Multivac](https://www.sunholo.com/).\n"
|
|
248
|
+
+ f"It used the model {model}, used {prompt_tokens} prompt tokens, "
|
|
249
|
+
+ f"and {completion_tokens} completion tokens in total."
|
|
250
|
+
},
|
|
251
|
+
headers=headers,
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
@serve.deployment(route_prefix="/")
|
|
255
|
+
@serve.ingress(app)
|
|
256
|
+
class ServeBot:
|
|
257
|
+
@app.get("/")
|
|
258
|
+
async def root(self):
|
|
259
|
+
return {"message": "Docu Mentor reporting for duty!"}
|
|
260
|
+
|
|
261
|
+
@app.post("/webhook/")
|
|
262
|
+
async def handle_webhook_route(self, request: Request):
|
|
263
|
+
return await handle_webhook(request)
|
|
264
|
+
|
sunholo/cli/configs.py
CHANGED
|
@@ -12,8 +12,8 @@ def validate_config(config, schema):
|
|
|
12
12
|
return True
|
|
13
13
|
except ValidationError as err:
|
|
14
14
|
error_path = " -> ".join(map(str, err.path))
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
print(f"ERROR: Validation error at '{error_path}': {err.message}")
|
|
16
|
+
return False
|
|
17
17
|
|
|
18
18
|
def list_configs(args):
|
|
19
19
|
"""
|
|
@@ -84,16 +84,12 @@ def list_configs(args):
|
|
|
84
84
|
print(f"Validating configuration for kind: {kind}")
|
|
85
85
|
if args.kind == "vacConfig" and args.vac:
|
|
86
86
|
print(f"Validating vacConfig for {args.vac}")
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
except ValidationError as e:
|
|
90
|
-
print(f"Validation failed for sub-kind: {args.vac} - {str(e)}")
|
|
87
|
+
if not validate_config(config[args.vac], VAC_SUBCONFIG_SCHEMA):
|
|
88
|
+
print(f"Validation failed for sub-kind: {args.vac}")
|
|
91
89
|
validation_failed = True
|
|
92
90
|
elif kind in SCHEMAS:
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
except ValidationError as e:
|
|
96
|
-
print(f"FAIL: Validation failed for kind: {kind} - - {str(e)}")
|
|
91
|
+
if not validate_config(config, SCHEMAS[kind]):
|
|
92
|
+
print(f"FAIL: Validation failed for kind: {kind}")
|
|
97
93
|
validation_failed = True
|
|
98
94
|
else:
|
|
99
95
|
print(f"No schema available to validate configuration for kind: {kind}")
|
sunholo/utils/config_schema.py
CHANGED
|
@@ -52,7 +52,8 @@ VAC_SUBCONFIG_SCHEMA = {
|
|
|
52
52
|
"cluster": {"type": "string"},
|
|
53
53
|
"instance": {"type": "string"},
|
|
54
54
|
"database": {"type": "string"}
|
|
55
|
-
}
|
|
55
|
+
},
|
|
56
|
+
"required": ["project_id", "region", "cluster", "instance", "database"]
|
|
56
57
|
},
|
|
57
58
|
"secrets": {
|
|
58
59
|
"type": "array",
|
|
@@ -87,7 +88,7 @@ VAC_CONFIG_SCHEMA = {
|
|
|
87
88
|
}
|
|
88
89
|
}
|
|
89
90
|
},
|
|
90
|
-
"required": ["kind", "apiVersion", "
|
|
91
|
+
"required": ["kind", "apiVersion", "vac"]
|
|
91
92
|
}
|
|
92
93
|
|
|
93
94
|
PROMPT_CONFIG_SCHEMA = {
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: sunholo
|
|
3
|
-
Version: 0.59.
|
|
3
|
+
Version: 0.59.3
|
|
4
4
|
Summary: Large Language Model DevOps - a package to help deploy LLMs to the Cloud.
|
|
5
5
|
Home-page: https://github.com/sunholo-data/sunholo-py
|
|
6
|
-
Download-URL: https://github.com/sunholo-data/sunholo-py/archive/refs/tags/v0.59.
|
|
6
|
+
Download-URL: https://github.com/sunholo-data/sunholo-py/archive/refs/tags/v0.59.3.tar.gz
|
|
7
7
|
Author: Holosun ApS
|
|
8
8
|
Author-email: multivac@sunholo.com
|
|
9
9
|
License: Apache License, Version 2.0
|
|
@@ -20,6 +20,7 @@ sunholo/auth/__init__.py,sha256=4owDjSaWYkbTlPK47UHTOC0gCWbZsqn4ZIEw5NWZTlg,28
|
|
|
20
20
|
sunholo/auth/run.py,sha256=4fhNhDgtMtBHc08AywDmtazQPQ2560at6EMLQbMwTIo,2846
|
|
21
21
|
sunholo/bots/__init__.py,sha256=EMFd7e2z68l6pzYOnkzHbLd2xJRvxTKFRNCTuhZ8hIw,130
|
|
22
22
|
sunholo/bots/discord.py,sha256=cCFae5K1BCa6JVkWGLh_iZ9qFO1JpXb6K4eJrlDfEro,2442
|
|
23
|
+
sunholo/bots/github_webhook.py,sha256=5pQPRLM_wxxcILVaIzUDV8Kt7Arcm2dL1r1kMMHA524,9629
|
|
23
24
|
sunholo/bots/webapp.py,sha256=EIMxdAJ_xtufwJmvnn7N_Fb_1hZ9DjhJ0Kf_hp02vEU,1926
|
|
24
25
|
sunholo/chunker/__init__.py,sha256=UhQBZTKwDfBXm0TPv4LvsGc5pdUGCbYzi3qPTOkU4gw,55
|
|
25
26
|
sunholo/chunker/data_to_embed_pubsub.py,sha256=t-pWNYv2mnwVAkMcIOK2CrIb3yr2aS9iAdtryk7hT8o,2931
|
|
@@ -33,7 +34,7 @@ sunholo/chunker/splitter.py,sha256=ug_v-h0wos3b7OkhmedVQs5jtLuDdFDWypvsZVYgxbU,6
|
|
|
33
34
|
sunholo/cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
34
35
|
sunholo/cli/cli.py,sha256=rcO1hMthy5nWC_5sOHqRm7ut70c9JfxFTSjFRBNYuYg,1248
|
|
35
36
|
sunholo/cli/cli_init.py,sha256=WReZuMQwDfkRUvssYT7TirUoG6SiT1dTDol8nLI8O70,3418
|
|
36
|
-
sunholo/cli/configs.py,sha256=
|
|
37
|
+
sunholo/cli/configs.py,sha256=jHCNz_rANlQI2ZCWnlgJu5QwQc-a_Koi9Hm3XHjHEpE,4608
|
|
37
38
|
sunholo/cli/deploy.py,sha256=zxdwUsRTRMC8U5vyRv0JiKBLFn84Ug_Tc88-_h9hJSs,1609
|
|
38
39
|
sunholo/components/__init__.py,sha256=RJGNEihwvRIiDScKis04RHJv4yZGI1UpXlOmuCptNZI,208
|
|
39
40
|
sunholo/components/llm.py,sha256=T4we3tGmqUj4tPwxQr9M6AXv_BALqZV_dRSvINan-oU,10374
|
|
@@ -85,14 +86,14 @@ sunholo/summarise/summarise.py,sha256=C3HhjepTjUhUC8FLk4jMQIBvq1BcORniwuTFHjPVhV
|
|
|
85
86
|
sunholo/utils/__init__.py,sha256=G11nN_6ATjxpuMfG_BvcUr9UU8onPIgkpTK6CjOcbr8,48
|
|
86
87
|
sunholo/utils/big_context.py,sha256=qHYtds4Ecf9eZRHVqXho4_q8Je7HD44-vS6RJ6s9Z0Q,5387
|
|
87
88
|
sunholo/utils/config.py,sha256=Ve1sb68Av9_SPGqXs33g5FAJSIQ3GODoeuUCW3MNCwU,8802
|
|
88
|
-
sunholo/utils/config_schema.py,sha256=
|
|
89
|
+
sunholo/utils/config_schema.py,sha256=Rkw5nVHcCtIQH_sH5ZUiDaxW1TOjUmzsDwHhrfiWeqQ,3829
|
|
89
90
|
sunholo/utils/gcp.py,sha256=B2G1YKjeD7X9dqO86Jrp2vPuFwZ223Xl5Tg09Ndw-oc,5760
|
|
90
91
|
sunholo/utils/parsers.py,sha256=OrHmASqIbI45atVOhiGodgLvnfrzkvVzyHnSvAXD89I,3841
|
|
91
92
|
sunholo/vertex/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
92
93
|
sunholo/vertex/init_vertex.py,sha256=JDMUaBRdednzbKF-5p33qqLit2LMsvgvWW-NRz0AqO0,1801
|
|
93
|
-
sunholo-0.59.
|
|
94
|
-
sunholo-0.59.
|
|
95
|
-
sunholo-0.59.
|
|
96
|
-
sunholo-0.59.
|
|
97
|
-
sunholo-0.59.
|
|
98
|
-
sunholo-0.59.
|
|
94
|
+
sunholo-0.59.3.dist-info/LICENSE.txt,sha256=SdE3QjnD3GEmqqg9EX3TM9f7WmtOzqS1KJve8rhbYmU,11345
|
|
95
|
+
sunholo-0.59.3.dist-info/METADATA,sha256=XAF0rooghlKcIPwDYbNNHt6dv5k06WF_PyV1t6GKl9E,7903
|
|
96
|
+
sunholo-0.59.3.dist-info/WHEEL,sha256=GJ7t_kWBFywbagK5eo9IoUwLW6oyOeTKmQ-9iHFVNxQ,92
|
|
97
|
+
sunholo-0.59.3.dist-info/entry_points.txt,sha256=bZuN5AIHingMPt4Ro1b_T-FnQvZ3teBes-3OyO0asl4,49
|
|
98
|
+
sunholo-0.59.3.dist-info/top_level.txt,sha256=wt5tadn5--5JrZsjJz2LceoUvcrIvxjHJe-RxuudxAk,8
|
|
99
|
+
sunholo-0.59.3.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|