pierre-storage 0.9.0__tar.gz → 0.11.0__tar.gz
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.
- {pierre_storage-0.9.0/pierre_storage.egg-info → pierre_storage-0.11.0}/PKG-INFO +26 -1
- {pierre_storage-0.9.0 → pierre_storage-0.11.0}/README.md +25 -0
- {pierre_storage-0.9.0 → pierre_storage-0.11.0}/pierre_storage/__init__.py +8 -0
- {pierre_storage-0.9.0 → pierre_storage-0.11.0}/pierre_storage/client.py +63 -0
- {pierre_storage-0.9.0 → pierre_storage-0.11.0}/pierre_storage/repo.py +235 -0
- {pierre_storage-0.9.0 → pierre_storage-0.11.0}/pierre_storage/types.py +98 -0
- {pierre_storage-0.9.0 → pierre_storage-0.11.0/pierre_storage.egg-info}/PKG-INFO +26 -1
- {pierre_storage-0.9.0 → pierre_storage-0.11.0}/pyproject.toml +1 -1
- {pierre_storage-0.9.0 → pierre_storage-0.11.0}/tests/test_client.py +62 -0
- {pierre_storage-0.9.0 → pierre_storage-0.11.0}/tests/test_repo.py +112 -0
- {pierre_storage-0.9.0 → pierre_storage-0.11.0}/LICENSE +0 -0
- {pierre_storage-0.9.0 → pierre_storage-0.11.0}/MANIFEST.in +0 -0
- {pierre_storage-0.9.0 → pierre_storage-0.11.0}/pierre_storage/auth.py +0 -0
- {pierre_storage-0.9.0 → pierre_storage-0.11.0}/pierre_storage/commit.py +0 -0
- {pierre_storage-0.9.0 → pierre_storage-0.11.0}/pierre_storage/errors.py +0 -0
- {pierre_storage-0.9.0 → pierre_storage-0.11.0}/pierre_storage/py.typed +0 -0
- {pierre_storage-0.9.0 → pierre_storage-0.11.0}/pierre_storage/version.py +0 -0
- {pierre_storage-0.9.0 → pierre_storage-0.11.0}/pierre_storage/webhook.py +0 -0
- {pierre_storage-0.9.0 → pierre_storage-0.11.0}/pierre_storage.egg-info/SOURCES.txt +0 -0
- {pierre_storage-0.9.0 → pierre_storage-0.11.0}/pierre_storage.egg-info/dependency_links.txt +0 -0
- {pierre_storage-0.9.0 → pierre_storage-0.11.0}/pierre_storage.egg-info/requires.txt +0 -0
- {pierre_storage-0.9.0 → pierre_storage-0.11.0}/pierre_storage.egg-info/top_level.txt +0 -0
- {pierre_storage-0.9.0 → pierre_storage-0.11.0}/setup.cfg +0 -0
- {pierre_storage-0.9.0 → pierre_storage-0.11.0}/tests/test_commit.py +0 -0
- {pierre_storage-0.9.0 → pierre_storage-0.11.0}/tests/test_version.py +0 -0
- {pierre_storage-0.9.0 → pierre_storage-0.11.0}/tests/test_webhook.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pierre-storage
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.11.0
|
|
4
4
|
Summary: Pierre Git Storage SDK for Python
|
|
5
5
|
Author-email: Pierre <support@pierre.io>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -149,6 +149,10 @@ repo = await storage.create_repo()
|
|
|
149
149
|
# or
|
|
150
150
|
repo = await storage.find_one(id="existing-repo-id")
|
|
151
151
|
|
|
152
|
+
# List repositories for the org
|
|
153
|
+
repos = await storage.list_repos(limit=20)
|
|
154
|
+
print(repos["repos"])
|
|
155
|
+
|
|
152
156
|
# Get file content (streaming)
|
|
153
157
|
response = await repo.get_file_stream(
|
|
154
158
|
path="README.md",
|
|
@@ -190,6 +194,27 @@ commits = await repo.list_commits(
|
|
|
190
194
|
)
|
|
191
195
|
print(commits["commits"])
|
|
192
196
|
|
|
197
|
+
# Read a git note for a commit
|
|
198
|
+
note = await repo.get_note(sha="abc123...")
|
|
199
|
+
print(note["note"])
|
|
200
|
+
|
|
201
|
+
# Add a git note
|
|
202
|
+
note_result = await repo.create_note(
|
|
203
|
+
sha="abc123...",
|
|
204
|
+
note="Release QA approved",
|
|
205
|
+
author={"name": "Release Bot", "email": "release@example.com"},
|
|
206
|
+
)
|
|
207
|
+
print(note_result["new_ref_sha"])
|
|
208
|
+
|
|
209
|
+
# Append to a git note
|
|
210
|
+
await repo.append_note(
|
|
211
|
+
sha="abc123...",
|
|
212
|
+
note="Follow-up review complete",
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
# Delete a git note
|
|
216
|
+
await repo.delete_note(sha="abc123...")
|
|
217
|
+
|
|
193
218
|
# Get branch diff
|
|
194
219
|
branch_diff = await repo.get_branch_diff(
|
|
195
220
|
branch="feature-branch",
|
|
@@ -113,6 +113,10 @@ repo = await storage.create_repo()
|
|
|
113
113
|
# or
|
|
114
114
|
repo = await storage.find_one(id="existing-repo-id")
|
|
115
115
|
|
|
116
|
+
# List repositories for the org
|
|
117
|
+
repos = await storage.list_repos(limit=20)
|
|
118
|
+
print(repos["repos"])
|
|
119
|
+
|
|
116
120
|
# Get file content (streaming)
|
|
117
121
|
response = await repo.get_file_stream(
|
|
118
122
|
path="README.md",
|
|
@@ -154,6 +158,27 @@ commits = await repo.list_commits(
|
|
|
154
158
|
)
|
|
155
159
|
print(commits["commits"])
|
|
156
160
|
|
|
161
|
+
# Read a git note for a commit
|
|
162
|
+
note = await repo.get_note(sha="abc123...")
|
|
163
|
+
print(note["note"])
|
|
164
|
+
|
|
165
|
+
# Add a git note
|
|
166
|
+
note_result = await repo.create_note(
|
|
167
|
+
sha="abc123...",
|
|
168
|
+
note="Release QA approved",
|
|
169
|
+
author={"name": "Release Bot", "email": "release@example.com"},
|
|
170
|
+
)
|
|
171
|
+
print(note_result["new_ref_sha"])
|
|
172
|
+
|
|
173
|
+
# Append to a git note
|
|
174
|
+
await repo.append_note(
|
|
175
|
+
sha="abc123...",
|
|
176
|
+
note="Follow-up review complete",
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
# Delete a git note
|
|
180
|
+
await repo.delete_note(sha="abc123...")
|
|
181
|
+
|
|
157
182
|
# Get branch diff
|
|
158
183
|
branch_diff = await repo.get_branch_diff(
|
|
159
184
|
branch="feature-branch",
|
|
@@ -27,8 +27,12 @@ from pierre_storage.types import (
|
|
|
27
27
|
ListBranchesResult,
|
|
28
28
|
ListCommitsResult,
|
|
29
29
|
ListFilesResult,
|
|
30
|
+
ListReposResult,
|
|
31
|
+
NoteReadResult,
|
|
32
|
+
NoteWriteResult,
|
|
30
33
|
RefUpdate,
|
|
31
34
|
Repo,
|
|
35
|
+
RepoInfo,
|
|
32
36
|
RestoreCommitResult,
|
|
33
37
|
)
|
|
34
38
|
from pierre_storage.version import PACKAGE_VERSION
|
|
@@ -71,7 +75,11 @@ __all__ = [
|
|
|
71
75
|
"ListBranchesResult",
|
|
72
76
|
"ListCommitsResult",
|
|
73
77
|
"ListFilesResult",
|
|
78
|
+
"ListReposResult",
|
|
79
|
+
"NoteReadResult",
|
|
80
|
+
"NoteWriteResult",
|
|
74
81
|
"RefUpdate",
|
|
82
|
+
"RepoInfo",
|
|
75
83
|
"Repo",
|
|
76
84
|
"RestoreCommitResult",
|
|
77
85
|
# Webhook
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import uuid
|
|
4
4
|
from typing import Any, Dict, Optional
|
|
5
|
+
from urllib.parse import urlencode
|
|
5
6
|
|
|
6
7
|
import httpx
|
|
7
8
|
|
|
@@ -11,7 +12,9 @@ from pierre_storage.repo import DEFAULT_TOKEN_TTL_SECONDS, RepoImpl
|
|
|
11
12
|
from pierre_storage.types import (
|
|
12
13
|
DeleteRepoResult,
|
|
13
14
|
GitStorageOptions,
|
|
15
|
+
ListReposResult,
|
|
14
16
|
Repo,
|
|
17
|
+
RepoInfo,
|
|
15
18
|
)
|
|
16
19
|
from pierre_storage.version import get_user_agent
|
|
17
20
|
|
|
@@ -178,6 +181,66 @@ class GitStorage:
|
|
|
178
181
|
self._generate_jwt,
|
|
179
182
|
)
|
|
180
183
|
|
|
184
|
+
async def list_repos(
|
|
185
|
+
self,
|
|
186
|
+
*,
|
|
187
|
+
cursor: Optional[str] = None,
|
|
188
|
+
limit: Optional[int] = None,
|
|
189
|
+
ttl: Optional[int] = None,
|
|
190
|
+
) -> ListReposResult:
|
|
191
|
+
"""List repositories for the organization."""
|
|
192
|
+
ttl = ttl or DEFAULT_TOKEN_TTL_SECONDS
|
|
193
|
+
jwt = self._generate_jwt(
|
|
194
|
+
"org",
|
|
195
|
+
{"permissions": ["org:read"], "ttl": ttl},
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
params: Dict[str, str] = {}
|
|
199
|
+
if cursor:
|
|
200
|
+
params["cursor"] = cursor
|
|
201
|
+
if limit is not None:
|
|
202
|
+
params["limit"] = str(limit)
|
|
203
|
+
|
|
204
|
+
url = f"{self.options['api_base_url']}/api/v{self.options['api_version']}/repos"
|
|
205
|
+
if params:
|
|
206
|
+
url += f"?{urlencode(params)}"
|
|
207
|
+
|
|
208
|
+
async with httpx.AsyncClient() as client:
|
|
209
|
+
response = await client.get(
|
|
210
|
+
url,
|
|
211
|
+
headers={
|
|
212
|
+
"Authorization": f"Bearer {jwt}",
|
|
213
|
+
"Code-Storage-Agent": get_user_agent(),
|
|
214
|
+
},
|
|
215
|
+
timeout=30.0,
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
if not response.is_success:
|
|
219
|
+
raise ApiError(
|
|
220
|
+
f"Failed to list repositories: {response.status_code} {response.reason_phrase}",
|
|
221
|
+
status_code=response.status_code,
|
|
222
|
+
response=response,
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
data = response.json()
|
|
226
|
+
repos: list[RepoInfo] = []
|
|
227
|
+
for repo in data.get("repos", []):
|
|
228
|
+
entry: RepoInfo = {
|
|
229
|
+
"repo_id": repo.get("repo_id", ""),
|
|
230
|
+
"url": repo.get("url", ""),
|
|
231
|
+
"default_branch": repo.get("default_branch", "main"),
|
|
232
|
+
"created_at": repo.get("created_at", ""),
|
|
233
|
+
}
|
|
234
|
+
if repo.get("base_repo"):
|
|
235
|
+
entry["base_repo"] = repo.get("base_repo")
|
|
236
|
+
repos.append(entry)
|
|
237
|
+
|
|
238
|
+
return {
|
|
239
|
+
"repos": repos,
|
|
240
|
+
"next_cursor": data.get("next_cursor"),
|
|
241
|
+
"has_more": data.get("has_more", False),
|
|
242
|
+
}
|
|
243
|
+
|
|
181
244
|
async def find_one(self, *, id: str) -> Optional[Repo]:
|
|
182
245
|
"""Find a repository by ID.
|
|
183
246
|
|
|
@@ -33,6 +33,8 @@ from pierre_storage.types import (
|
|
|
33
33
|
ListBranchesResult,
|
|
34
34
|
ListCommitsResult,
|
|
35
35
|
ListFilesResult,
|
|
36
|
+
NoteReadResult,
|
|
37
|
+
NoteWriteResult,
|
|
36
38
|
RefUpdate,
|
|
37
39
|
RestoreCommitResult,
|
|
38
40
|
)
|
|
@@ -498,6 +500,122 @@ class RepoImpl:
|
|
|
498
500
|
"has_more": data["has_more"],
|
|
499
501
|
}
|
|
500
502
|
|
|
503
|
+
async def get_note(
|
|
504
|
+
self,
|
|
505
|
+
*,
|
|
506
|
+
sha: str,
|
|
507
|
+
ttl: Optional[int] = None,
|
|
508
|
+
) -> NoteReadResult:
|
|
509
|
+
"""Read a git note."""
|
|
510
|
+
sha_clean = sha.strip()
|
|
511
|
+
if not sha_clean:
|
|
512
|
+
raise ValueError("get_note sha is required")
|
|
513
|
+
|
|
514
|
+
ttl = ttl or DEFAULT_TOKEN_TTL_SECONDS
|
|
515
|
+
jwt = self.generate_jwt(self._id, {"permissions": ["git:read"], "ttl": ttl})
|
|
516
|
+
|
|
517
|
+
url = f"{self.api_base_url}/api/v{self.api_version}/repos/notes?{urlencode({'sha': sha_clean})}"
|
|
518
|
+
|
|
519
|
+
async with httpx.AsyncClient() as client:
|
|
520
|
+
response = await client.get(
|
|
521
|
+
url,
|
|
522
|
+
headers={
|
|
523
|
+
"Authorization": f"Bearer {jwt}",
|
|
524
|
+
"Code-Storage-Agent": get_user_agent(),
|
|
525
|
+
},
|
|
526
|
+
timeout=30.0,
|
|
527
|
+
)
|
|
528
|
+
response.raise_for_status()
|
|
529
|
+
data = response.json()
|
|
530
|
+
return {
|
|
531
|
+
"sha": data["sha"],
|
|
532
|
+
"note": data["note"],
|
|
533
|
+
"ref_sha": data["ref_sha"],
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
async def create_note(
|
|
537
|
+
self,
|
|
538
|
+
*,
|
|
539
|
+
sha: str,
|
|
540
|
+
note: str,
|
|
541
|
+
expected_ref_sha: Optional[str] = None,
|
|
542
|
+
author: Optional[CommitSignature] = None,
|
|
543
|
+
ttl: Optional[int] = None,
|
|
544
|
+
) -> NoteWriteResult:
|
|
545
|
+
"""Create a git note."""
|
|
546
|
+
return await self._write_note(
|
|
547
|
+
action_label="create_note",
|
|
548
|
+
action="add",
|
|
549
|
+
sha=sha,
|
|
550
|
+
note=note,
|
|
551
|
+
expected_ref_sha=expected_ref_sha,
|
|
552
|
+
author=author,
|
|
553
|
+
ttl=ttl,
|
|
554
|
+
)
|
|
555
|
+
|
|
556
|
+
async def append_note(
|
|
557
|
+
self,
|
|
558
|
+
*,
|
|
559
|
+
sha: str,
|
|
560
|
+
note: str,
|
|
561
|
+
expected_ref_sha: Optional[str] = None,
|
|
562
|
+
author: Optional[CommitSignature] = None,
|
|
563
|
+
ttl: Optional[int] = None,
|
|
564
|
+
) -> NoteWriteResult:
|
|
565
|
+
"""Append to a git note."""
|
|
566
|
+
return await self._write_note(
|
|
567
|
+
action_label="append_note",
|
|
568
|
+
action="append",
|
|
569
|
+
sha=sha,
|
|
570
|
+
note=note,
|
|
571
|
+
expected_ref_sha=expected_ref_sha,
|
|
572
|
+
author=author,
|
|
573
|
+
ttl=ttl,
|
|
574
|
+
)
|
|
575
|
+
|
|
576
|
+
async def delete_note(
|
|
577
|
+
self,
|
|
578
|
+
*,
|
|
579
|
+
sha: str,
|
|
580
|
+
expected_ref_sha: Optional[str] = None,
|
|
581
|
+
author: Optional[CommitSignature] = None,
|
|
582
|
+
ttl: Optional[int] = None,
|
|
583
|
+
) -> NoteWriteResult:
|
|
584
|
+
"""Delete a git note."""
|
|
585
|
+
sha_clean = sha.strip()
|
|
586
|
+
if not sha_clean:
|
|
587
|
+
raise ValueError("delete_note sha is required")
|
|
588
|
+
|
|
589
|
+
ttl = ttl or DEFAULT_TOKEN_TTL_SECONDS
|
|
590
|
+
jwt = self.generate_jwt(self._id, {"permissions": ["git:write"], "ttl": ttl})
|
|
591
|
+
|
|
592
|
+
payload: Dict[str, Any] = {"sha": sha_clean}
|
|
593
|
+
if expected_ref_sha and expected_ref_sha.strip():
|
|
594
|
+
payload["expected_ref_sha"] = expected_ref_sha.strip()
|
|
595
|
+
if author:
|
|
596
|
+
author_name = author.get("name", "").strip()
|
|
597
|
+
author_email = author.get("email", "").strip()
|
|
598
|
+
if not author_name or not author_email:
|
|
599
|
+
raise ValueError("delete_note author name and email are required when provided")
|
|
600
|
+
payload["author"] = {"name": author_name, "email": author_email}
|
|
601
|
+
|
|
602
|
+
url = f"{self.api_base_url}/api/v{self.api_version}/repos/notes"
|
|
603
|
+
|
|
604
|
+
async with httpx.AsyncClient() as client:
|
|
605
|
+
response = await client.request(
|
|
606
|
+
"DELETE",
|
|
607
|
+
url,
|
|
608
|
+
headers={
|
|
609
|
+
"Authorization": f"Bearer {jwt}",
|
|
610
|
+
"Content-Type": "application/json",
|
|
611
|
+
"Code-Storage-Agent": get_user_agent(),
|
|
612
|
+
},
|
|
613
|
+
json=payload,
|
|
614
|
+
timeout=30.0,
|
|
615
|
+
)
|
|
616
|
+
|
|
617
|
+
return self._parse_note_write_response(response, "delete_note")
|
|
618
|
+
|
|
501
619
|
async def get_branch_diff(
|
|
502
620
|
self,
|
|
503
621
|
*,
|
|
@@ -1046,6 +1164,123 @@ class RepoImpl:
|
|
|
1046
1164
|
self.api_version,
|
|
1047
1165
|
)
|
|
1048
1166
|
|
|
1167
|
+
async def _write_note(
|
|
1168
|
+
self,
|
|
1169
|
+
*,
|
|
1170
|
+
action_label: str,
|
|
1171
|
+
action: str,
|
|
1172
|
+
sha: str,
|
|
1173
|
+
note: str,
|
|
1174
|
+
expected_ref_sha: Optional[str],
|
|
1175
|
+
author: Optional[CommitSignature],
|
|
1176
|
+
ttl: Optional[int],
|
|
1177
|
+
) -> NoteWriteResult:
|
|
1178
|
+
sha_clean = sha.strip()
|
|
1179
|
+
if not sha_clean:
|
|
1180
|
+
raise ValueError(f"{action_label} sha is required")
|
|
1181
|
+
|
|
1182
|
+
note_clean = note.strip()
|
|
1183
|
+
if not note_clean:
|
|
1184
|
+
raise ValueError(f"{action_label} note is required")
|
|
1185
|
+
|
|
1186
|
+
ttl = ttl or DEFAULT_TOKEN_TTL_SECONDS
|
|
1187
|
+
jwt = self.generate_jwt(self._id, {"permissions": ["git:write"], "ttl": ttl})
|
|
1188
|
+
|
|
1189
|
+
payload: Dict[str, Any] = {
|
|
1190
|
+
"sha": sha_clean,
|
|
1191
|
+
"action": action,
|
|
1192
|
+
"note": note_clean,
|
|
1193
|
+
}
|
|
1194
|
+
if expected_ref_sha and expected_ref_sha.strip():
|
|
1195
|
+
payload["expected_ref_sha"] = expected_ref_sha.strip()
|
|
1196
|
+
if author:
|
|
1197
|
+
author_name = author.get("name", "").strip()
|
|
1198
|
+
author_email = author.get("email", "").strip()
|
|
1199
|
+
if not author_name or not author_email:
|
|
1200
|
+
raise ValueError(f"{action_label} author name and email are required when provided")
|
|
1201
|
+
payload["author"] = {"name": author_name, "email": author_email}
|
|
1202
|
+
|
|
1203
|
+
url = f"{self.api_base_url}/api/v{self.api_version}/repos/notes"
|
|
1204
|
+
|
|
1205
|
+
async with httpx.AsyncClient() as client:
|
|
1206
|
+
response = await client.post(
|
|
1207
|
+
url,
|
|
1208
|
+
headers={
|
|
1209
|
+
"Authorization": f"Bearer {jwt}",
|
|
1210
|
+
"Content-Type": "application/json",
|
|
1211
|
+
"Code-Storage-Agent": get_user_agent(),
|
|
1212
|
+
},
|
|
1213
|
+
json=payload,
|
|
1214
|
+
timeout=30.0,
|
|
1215
|
+
)
|
|
1216
|
+
|
|
1217
|
+
return self._parse_note_write_response(response, action_label)
|
|
1218
|
+
|
|
1219
|
+
def _parse_note_write_response(
|
|
1220
|
+
self,
|
|
1221
|
+
response: httpx.Response,
|
|
1222
|
+
action_label: str,
|
|
1223
|
+
) -> NoteWriteResult:
|
|
1224
|
+
try:
|
|
1225
|
+
payload = response.json()
|
|
1226
|
+
except Exception as exc:
|
|
1227
|
+
message = f"{action_label} failed with HTTP {response.status_code}"
|
|
1228
|
+
if response.reason_phrase:
|
|
1229
|
+
message += f" {response.reason_phrase}"
|
|
1230
|
+
try:
|
|
1231
|
+
body_text = response.text
|
|
1232
|
+
except Exception:
|
|
1233
|
+
body_text = ""
|
|
1234
|
+
if body_text:
|
|
1235
|
+
message += f": {body_text[:200]}"
|
|
1236
|
+
raise ApiError(message, status_code=response.status_code, response=response) from exc
|
|
1237
|
+
|
|
1238
|
+
if isinstance(payload, dict) and "error" in payload:
|
|
1239
|
+
raise ApiError(
|
|
1240
|
+
str(payload.get("error")),
|
|
1241
|
+
status_code=response.status_code,
|
|
1242
|
+
response=response,
|
|
1243
|
+
)
|
|
1244
|
+
|
|
1245
|
+
if not isinstance(payload, dict) or "result" not in payload:
|
|
1246
|
+
message = f"{action_label} failed with HTTP {response.status_code}"
|
|
1247
|
+
if response.reason_phrase:
|
|
1248
|
+
message += f" {response.reason_phrase}"
|
|
1249
|
+
raise ApiError(message, status_code=response.status_code, response=response)
|
|
1250
|
+
|
|
1251
|
+
result = payload.get("result", {})
|
|
1252
|
+
note_result: NoteWriteResult = {
|
|
1253
|
+
"sha": payload.get("sha", ""),
|
|
1254
|
+
"target_ref": payload.get("target_ref", ""),
|
|
1255
|
+
"new_ref_sha": payload.get("new_ref_sha", ""),
|
|
1256
|
+
"result": {
|
|
1257
|
+
"success": bool(result.get("success")),
|
|
1258
|
+
"status": str(result.get("status", "")),
|
|
1259
|
+
},
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
base_commit = payload.get("base_commit")
|
|
1263
|
+
if isinstance(base_commit, str) and base_commit:
|
|
1264
|
+
note_result["base_commit"] = base_commit
|
|
1265
|
+
if result.get("message"):
|
|
1266
|
+
note_result["result"]["message"] = result.get("message")
|
|
1267
|
+
|
|
1268
|
+
if not result.get("success"):
|
|
1269
|
+
raise RefUpdateError(
|
|
1270
|
+
result.get(
|
|
1271
|
+
"message",
|
|
1272
|
+
f"{action_label} failed with status {result.get('status')}",
|
|
1273
|
+
),
|
|
1274
|
+
status=result.get("status"),
|
|
1275
|
+
ref_update={
|
|
1276
|
+
"branch": payload.get("target_ref", ""),
|
|
1277
|
+
"old_sha": payload.get("base_commit", ""),
|
|
1278
|
+
"new_sha": payload.get("new_ref_sha", ""),
|
|
1279
|
+
},
|
|
1280
|
+
)
|
|
1281
|
+
|
|
1282
|
+
return note_result
|
|
1283
|
+
|
|
1049
1284
|
def _to_ref_update(self, result: Dict[str, Any]) -> RefUpdate:
|
|
1050
1285
|
"""Convert result to ref update."""
|
|
1051
1286
|
return {
|
|
@@ -57,6 +57,33 @@ class DeleteRepoResult(TypedDict):
|
|
|
57
57
|
message: str
|
|
58
58
|
|
|
59
59
|
|
|
60
|
+
# Repository list types
|
|
61
|
+
class RepoBaseInfo(TypedDict):
|
|
62
|
+
"""Base repository info for listed repositories."""
|
|
63
|
+
|
|
64
|
+
provider: str
|
|
65
|
+
owner: str
|
|
66
|
+
name: str
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class RepoInfo(TypedDict, total=False):
|
|
70
|
+
"""Repository info in list responses."""
|
|
71
|
+
|
|
72
|
+
repo_id: str
|
|
73
|
+
url: str
|
|
74
|
+
default_branch: str
|
|
75
|
+
created_at: str
|
|
76
|
+
base_repo: NotRequired[RepoBaseInfo]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class ListReposResult(TypedDict):
|
|
80
|
+
"""Result from listing repositories."""
|
|
81
|
+
|
|
82
|
+
repos: List[RepoInfo]
|
|
83
|
+
next_cursor: Optional[str]
|
|
84
|
+
has_more: bool
|
|
85
|
+
|
|
86
|
+
|
|
60
87
|
# Removed: GetRemoteURLOptions - now uses **kwargs
|
|
61
88
|
# Removed: CreateRepoOptions - now uses **kwargs
|
|
62
89
|
# Removed: FindOneOptions - now uses **kwargs
|
|
@@ -127,6 +154,33 @@ class ListCommitsResult(TypedDict):
|
|
|
127
154
|
has_more: bool
|
|
128
155
|
|
|
129
156
|
|
|
157
|
+
# Git notes types
|
|
158
|
+
class NoteReadResult(TypedDict):
|
|
159
|
+
"""Result from reading a git note."""
|
|
160
|
+
|
|
161
|
+
sha: str
|
|
162
|
+
note: str
|
|
163
|
+
ref_sha: str
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class NoteWriteResultPayload(TypedDict):
|
|
167
|
+
"""Result payload for note writes."""
|
|
168
|
+
|
|
169
|
+
success: bool
|
|
170
|
+
status: str
|
|
171
|
+
message: NotRequired[str]
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class NoteWriteResult(TypedDict):
|
|
175
|
+
"""Result from writing a git note."""
|
|
176
|
+
|
|
177
|
+
sha: str
|
|
178
|
+
target_ref: str
|
|
179
|
+
base_commit: NotRequired[str]
|
|
180
|
+
new_ref_sha: str
|
|
181
|
+
result: NoteWriteResultPayload
|
|
182
|
+
|
|
183
|
+
|
|
130
184
|
# Diff types
|
|
131
185
|
class DiffStats(TypedDict):
|
|
132
186
|
"""Statistics about a diff."""
|
|
@@ -417,6 +471,50 @@ class Repo(Protocol):
|
|
|
417
471
|
"""List commits in the repository."""
|
|
418
472
|
...
|
|
419
473
|
|
|
474
|
+
async def get_note(
|
|
475
|
+
self,
|
|
476
|
+
*,
|
|
477
|
+
sha: str,
|
|
478
|
+
ttl: Optional[int] = None,
|
|
479
|
+
) -> NoteReadResult:
|
|
480
|
+
"""Read a git note."""
|
|
481
|
+
...
|
|
482
|
+
|
|
483
|
+
async def create_note(
|
|
484
|
+
self,
|
|
485
|
+
*,
|
|
486
|
+
sha: str,
|
|
487
|
+
note: str,
|
|
488
|
+
expected_ref_sha: Optional[str] = None,
|
|
489
|
+
author: Optional["CommitSignature"] = None,
|
|
490
|
+
ttl: Optional[int] = None,
|
|
491
|
+
) -> NoteWriteResult:
|
|
492
|
+
"""Create a git note."""
|
|
493
|
+
...
|
|
494
|
+
|
|
495
|
+
async def append_note(
|
|
496
|
+
self,
|
|
497
|
+
*,
|
|
498
|
+
sha: str,
|
|
499
|
+
note: str,
|
|
500
|
+
expected_ref_sha: Optional[str] = None,
|
|
501
|
+
author: Optional["CommitSignature"] = None,
|
|
502
|
+
ttl: Optional[int] = None,
|
|
503
|
+
) -> NoteWriteResult:
|
|
504
|
+
"""Append to a git note."""
|
|
505
|
+
...
|
|
506
|
+
|
|
507
|
+
async def delete_note(
|
|
508
|
+
self,
|
|
509
|
+
*,
|
|
510
|
+
sha: str,
|
|
511
|
+
expected_ref_sha: Optional[str] = None,
|
|
512
|
+
author: Optional["CommitSignature"] = None,
|
|
513
|
+
ttl: Optional[int] = None,
|
|
514
|
+
) -> NoteWriteResult:
|
|
515
|
+
"""Delete a git note."""
|
|
516
|
+
...
|
|
517
|
+
|
|
420
518
|
async def get_branch_diff(
|
|
421
519
|
self,
|
|
422
520
|
*,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pierre-storage
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.11.0
|
|
4
4
|
Summary: Pierre Git Storage SDK for Python
|
|
5
5
|
Author-email: Pierre <support@pierre.io>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -149,6 +149,10 @@ repo = await storage.create_repo()
|
|
|
149
149
|
# or
|
|
150
150
|
repo = await storage.find_one(id="existing-repo-id")
|
|
151
151
|
|
|
152
|
+
# List repositories for the org
|
|
153
|
+
repos = await storage.list_repos(limit=20)
|
|
154
|
+
print(repos["repos"])
|
|
155
|
+
|
|
152
156
|
# Get file content (streaming)
|
|
153
157
|
response = await repo.get_file_stream(
|
|
154
158
|
path="README.md",
|
|
@@ -190,6 +194,27 @@ commits = await repo.list_commits(
|
|
|
190
194
|
)
|
|
191
195
|
print(commits["commits"])
|
|
192
196
|
|
|
197
|
+
# Read a git note for a commit
|
|
198
|
+
note = await repo.get_note(sha="abc123...")
|
|
199
|
+
print(note["note"])
|
|
200
|
+
|
|
201
|
+
# Add a git note
|
|
202
|
+
note_result = await repo.create_note(
|
|
203
|
+
sha="abc123...",
|
|
204
|
+
note="Release QA approved",
|
|
205
|
+
author={"name": "Release Bot", "email": "release@example.com"},
|
|
206
|
+
)
|
|
207
|
+
print(note_result["new_ref_sha"])
|
|
208
|
+
|
|
209
|
+
# Append to a git note
|
|
210
|
+
await repo.append_note(
|
|
211
|
+
sha="abc123...",
|
|
212
|
+
note="Follow-up review complete",
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
# Delete a git note
|
|
216
|
+
await repo.delete_note(sha="abc123...")
|
|
217
|
+
|
|
193
218
|
# Get branch diff
|
|
194
219
|
branch_diff = await repo.get_branch_diff(
|
|
195
220
|
branch="feature-branch",
|
|
@@ -164,6 +164,68 @@ class TestGitStorage:
|
|
|
164
164
|
with pytest.raises(ApiError, match="Repository already exists"):
|
|
165
165
|
await storage.create_repo(id="existing-repo")
|
|
166
166
|
|
|
167
|
+
@pytest.mark.asyncio
|
|
168
|
+
async def test_list_repos(self, git_storage_options: dict) -> None:
|
|
169
|
+
"""Test listing repositories."""
|
|
170
|
+
storage = GitStorage(git_storage_options)
|
|
171
|
+
|
|
172
|
+
mock_response = MagicMock()
|
|
173
|
+
mock_response.status_code = 200
|
|
174
|
+
mock_response.is_success = True
|
|
175
|
+
mock_response.json.return_value = {
|
|
176
|
+
"repos": [
|
|
177
|
+
{
|
|
178
|
+
"repo_id": "repo-1",
|
|
179
|
+
"url": "owner/repo-1",
|
|
180
|
+
"default_branch": "main",
|
|
181
|
+
"created_at": "2024-01-01T00:00:00Z",
|
|
182
|
+
"base_repo": {"provider": "github", "owner": "owner", "name": "repo-1"},
|
|
183
|
+
}
|
|
184
|
+
],
|
|
185
|
+
"next_cursor": None,
|
|
186
|
+
"has_more": False,
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
with patch("httpx.AsyncClient") as mock_client:
|
|
190
|
+
mock_get = AsyncMock(return_value=mock_response)
|
|
191
|
+
mock_client.return_value.__aenter__.return_value.get = mock_get
|
|
192
|
+
|
|
193
|
+
result = await storage.list_repos()
|
|
194
|
+
assert result["has_more"] is False
|
|
195
|
+
assert result["repos"][0]["repo_id"] == "repo-1"
|
|
196
|
+
|
|
197
|
+
call_kwargs = mock_get.call_args[1]
|
|
198
|
+
headers = call_kwargs["headers"]
|
|
199
|
+
token = headers["Authorization"].replace("Bearer ", "")
|
|
200
|
+
payload = jwt.decode(token, options={"verify_signature": False})
|
|
201
|
+
assert payload["scopes"] == ["org:read"]
|
|
202
|
+
assert payload["repo"] == "org"
|
|
203
|
+
|
|
204
|
+
@pytest.mark.asyncio
|
|
205
|
+
async def test_list_repos_with_cursor(self, git_storage_options: dict) -> None:
|
|
206
|
+
"""Test listing repositories with pagination."""
|
|
207
|
+
storage = GitStorage(git_storage_options)
|
|
208
|
+
|
|
209
|
+
mock_response = MagicMock()
|
|
210
|
+
mock_response.status_code = 200
|
|
211
|
+
mock_response.is_success = True
|
|
212
|
+
mock_response.json.return_value = {
|
|
213
|
+
"repos": [],
|
|
214
|
+
"next_cursor": "next",
|
|
215
|
+
"has_more": True,
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
with patch("httpx.AsyncClient") as mock_client:
|
|
219
|
+
mock_get = AsyncMock(return_value=mock_response)
|
|
220
|
+
mock_client.return_value.__aenter__.return_value.get = mock_get
|
|
221
|
+
|
|
222
|
+
await storage.list_repos(cursor="cursor-1", limit=10)
|
|
223
|
+
|
|
224
|
+
call_args = mock_get.call_args[0]
|
|
225
|
+
api_url = call_args[0]
|
|
226
|
+
assert "cursor=cursor-1" in api_url
|
|
227
|
+
assert "limit=10" in api_url
|
|
228
|
+
|
|
167
229
|
@pytest.mark.asyncio
|
|
168
230
|
async def test_find_one(self, git_storage_options: dict) -> None:
|
|
169
231
|
"""Test finding a repository."""
|
|
@@ -654,6 +654,118 @@ class TestRepoCommitOperations:
|
|
|
654
654
|
assert result["ref_update"]["old_sha"] == "old-sha"
|
|
655
655
|
|
|
656
656
|
|
|
657
|
+
class TestRepoNoteOperations:
|
|
658
|
+
"""Tests for git note operations."""
|
|
659
|
+
|
|
660
|
+
@pytest.mark.asyncio
|
|
661
|
+
async def test_get_note(self, git_storage_options: dict) -> None:
|
|
662
|
+
"""Test reading a git note."""
|
|
663
|
+
storage = GitStorage(git_storage_options)
|
|
664
|
+
|
|
665
|
+
create_response = MagicMock()
|
|
666
|
+
create_response.status_code = 200
|
|
667
|
+
create_response.is_success = True
|
|
668
|
+
create_response.json.return_value = {"repo_id": "test-repo"}
|
|
669
|
+
|
|
670
|
+
note_response = MagicMock()
|
|
671
|
+
note_response.status_code = 200
|
|
672
|
+
note_response.is_success = True
|
|
673
|
+
note_response.raise_for_status = MagicMock()
|
|
674
|
+
note_response.json.return_value = {
|
|
675
|
+
"sha": "abc123",
|
|
676
|
+
"note": "hello notes",
|
|
677
|
+
"ref_sha": "def456",
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
with patch("httpx.AsyncClient") as mock_client:
|
|
681
|
+
client_instance = mock_client.return_value.__aenter__.return_value
|
|
682
|
+
client_instance.post = AsyncMock(return_value=create_response)
|
|
683
|
+
client_instance.get = AsyncMock(return_value=note_response)
|
|
684
|
+
|
|
685
|
+
repo = await storage.create_repo(id="test-repo")
|
|
686
|
+
result = await repo.get_note(sha="abc123")
|
|
687
|
+
|
|
688
|
+
assert result["sha"] == "abc123"
|
|
689
|
+
assert result["note"] == "hello notes"
|
|
690
|
+
assert result["ref_sha"] == "def456"
|
|
691
|
+
|
|
692
|
+
@pytest.mark.asyncio
|
|
693
|
+
async def test_create_append_delete_note(self, git_storage_options: dict) -> None:
|
|
694
|
+
"""Test creating, appending, and deleting git notes."""
|
|
695
|
+
storage = GitStorage(git_storage_options)
|
|
696
|
+
|
|
697
|
+
create_response = MagicMock()
|
|
698
|
+
create_response.status_code = 200
|
|
699
|
+
create_response.is_success = True
|
|
700
|
+
create_response.json.return_value = {"repo_id": "test-repo"}
|
|
701
|
+
|
|
702
|
+
create_note_response = MagicMock()
|
|
703
|
+
create_note_response.status_code = 201
|
|
704
|
+
create_note_response.is_success = True
|
|
705
|
+
create_note_response.json.return_value = {
|
|
706
|
+
"sha": "abc123",
|
|
707
|
+
"target_ref": "refs/notes/commits",
|
|
708
|
+
"new_ref_sha": "def456",
|
|
709
|
+
"result": {"success": True, "status": "ok"},
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
append_note_response = MagicMock()
|
|
713
|
+
append_note_response.status_code = 200
|
|
714
|
+
append_note_response.is_success = True
|
|
715
|
+
append_note_response.json.return_value = {
|
|
716
|
+
"sha": "abc123",
|
|
717
|
+
"target_ref": "refs/notes/commits",
|
|
718
|
+
"new_ref_sha": "ghi789",
|
|
719
|
+
"result": {"success": True, "status": "ok"},
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
delete_note_response = MagicMock()
|
|
723
|
+
delete_note_response.status_code = 200
|
|
724
|
+
delete_note_response.is_success = True
|
|
725
|
+
delete_note_response.json.return_value = {
|
|
726
|
+
"sha": "abc123",
|
|
727
|
+
"target_ref": "refs/notes/commits",
|
|
728
|
+
"new_ref_sha": "ghi789",
|
|
729
|
+
"result": {"success": True, "status": "ok"},
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
with patch("httpx.AsyncClient") as mock_client:
|
|
733
|
+
client_instance = mock_client.return_value.__aenter__.return_value
|
|
734
|
+
client_instance.post = AsyncMock(
|
|
735
|
+
side_effect=[create_response, create_note_response, append_note_response]
|
|
736
|
+
)
|
|
737
|
+
client_instance.request = AsyncMock(return_value=delete_note_response)
|
|
738
|
+
|
|
739
|
+
repo = await storage.create_repo(id="test-repo")
|
|
740
|
+
|
|
741
|
+
create_result = await repo.create_note(sha="abc123", note="note content")
|
|
742
|
+
assert create_result["new_ref_sha"] == "def456"
|
|
743
|
+
|
|
744
|
+
append_result = await repo.append_note(sha="abc123", note="note append")
|
|
745
|
+
assert append_result["new_ref_sha"] == "ghi789"
|
|
746
|
+
|
|
747
|
+
delete_result = await repo.delete_note(sha="abc123")
|
|
748
|
+
assert delete_result["target_ref"] == "refs/notes/commits"
|
|
749
|
+
|
|
750
|
+
create_call = client_instance.post.call_args_list[1]
|
|
751
|
+
assert create_call.kwargs["json"] == {
|
|
752
|
+
"sha": "abc123",
|
|
753
|
+
"action": "add",
|
|
754
|
+
"note": "note content",
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
append_call = client_instance.post.call_args_list[2]
|
|
758
|
+
assert append_call.kwargs["json"] == {
|
|
759
|
+
"sha": "abc123",
|
|
760
|
+
"action": "append",
|
|
761
|
+
"note": "note append",
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
delete_call = client_instance.request.call_args_list[0]
|
|
765
|
+
assert delete_call.args[0] == "DELETE"
|
|
766
|
+
assert delete_call.kwargs["json"] == {"sha": "abc123"}
|
|
767
|
+
|
|
768
|
+
|
|
657
769
|
class TestRepoDiffOperations:
|
|
658
770
|
"""Tests for diff operations."""
|
|
659
771
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|