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.
Files changed (26) hide show
  1. {pierre_storage-0.9.0/pierre_storage.egg-info → pierre_storage-0.11.0}/PKG-INFO +26 -1
  2. {pierre_storage-0.9.0 → pierre_storage-0.11.0}/README.md +25 -0
  3. {pierre_storage-0.9.0 → pierre_storage-0.11.0}/pierre_storage/__init__.py +8 -0
  4. {pierre_storage-0.9.0 → pierre_storage-0.11.0}/pierre_storage/client.py +63 -0
  5. {pierre_storage-0.9.0 → pierre_storage-0.11.0}/pierre_storage/repo.py +235 -0
  6. {pierre_storage-0.9.0 → pierre_storage-0.11.0}/pierre_storage/types.py +98 -0
  7. {pierre_storage-0.9.0 → pierre_storage-0.11.0/pierre_storage.egg-info}/PKG-INFO +26 -1
  8. {pierre_storage-0.9.0 → pierre_storage-0.11.0}/pyproject.toml +1 -1
  9. {pierre_storage-0.9.0 → pierre_storage-0.11.0}/tests/test_client.py +62 -0
  10. {pierre_storage-0.9.0 → pierre_storage-0.11.0}/tests/test_repo.py +112 -0
  11. {pierre_storage-0.9.0 → pierre_storage-0.11.0}/LICENSE +0 -0
  12. {pierre_storage-0.9.0 → pierre_storage-0.11.0}/MANIFEST.in +0 -0
  13. {pierre_storage-0.9.0 → pierre_storage-0.11.0}/pierre_storage/auth.py +0 -0
  14. {pierre_storage-0.9.0 → pierre_storage-0.11.0}/pierre_storage/commit.py +0 -0
  15. {pierre_storage-0.9.0 → pierre_storage-0.11.0}/pierre_storage/errors.py +0 -0
  16. {pierre_storage-0.9.0 → pierre_storage-0.11.0}/pierre_storage/py.typed +0 -0
  17. {pierre_storage-0.9.0 → pierre_storage-0.11.0}/pierre_storage/version.py +0 -0
  18. {pierre_storage-0.9.0 → pierre_storage-0.11.0}/pierre_storage/webhook.py +0 -0
  19. {pierre_storage-0.9.0 → pierre_storage-0.11.0}/pierre_storage.egg-info/SOURCES.txt +0 -0
  20. {pierre_storage-0.9.0 → pierre_storage-0.11.0}/pierre_storage.egg-info/dependency_links.txt +0 -0
  21. {pierre_storage-0.9.0 → pierre_storage-0.11.0}/pierre_storage.egg-info/requires.txt +0 -0
  22. {pierre_storage-0.9.0 → pierre_storage-0.11.0}/pierre_storage.egg-info/top_level.txt +0 -0
  23. {pierre_storage-0.9.0 → pierre_storage-0.11.0}/setup.cfg +0 -0
  24. {pierre_storage-0.9.0 → pierre_storage-0.11.0}/tests/test_commit.py +0 -0
  25. {pierre_storage-0.9.0 → pierre_storage-0.11.0}/tests/test_version.py +0 -0
  26. {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.9.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.9.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",
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "pierre-storage"
7
- version = "0.9.0"
7
+ version = "0.11.0"
8
8
  description = "Pierre Git Storage SDK for Python"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -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