pierre-storage 0.10.0__tar.gz → 0.12.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.10.0/pierre_storage.egg-info → pierre_storage-0.12.0}/PKG-INFO +65 -5
  2. {pierre_storage-0.10.0 → pierre_storage-0.12.0}/README.md +64 -4
  3. {pierre_storage-0.10.0 → pierre_storage-0.12.0}/pierre_storage/__init__.py +8 -0
  4. {pierre_storage-0.10.0 → pierre_storage-0.12.0}/pierre_storage/client.py +116 -17
  5. {pierre_storage-0.10.0 → pierre_storage-0.12.0}/pierre_storage/repo.py +235 -0
  6. {pierre_storage-0.10.0 → pierre_storage-0.12.0}/pierre_storage/types.py +110 -1
  7. {pierre_storage-0.10.0 → pierre_storage-0.12.0/pierre_storage.egg-info}/PKG-INFO +65 -5
  8. {pierre_storage-0.10.0 → pierre_storage-0.12.0}/pyproject.toml +1 -1
  9. {pierre_storage-0.10.0 → pierre_storage-0.12.0}/tests/test_client.py +98 -0
  10. {pierre_storage-0.10.0 → pierre_storage-0.12.0}/tests/test_repo.py +112 -0
  11. {pierre_storage-0.10.0 → pierre_storage-0.12.0}/LICENSE +0 -0
  12. {pierre_storage-0.10.0 → pierre_storage-0.12.0}/MANIFEST.in +0 -0
  13. {pierre_storage-0.10.0 → pierre_storage-0.12.0}/pierre_storage/auth.py +0 -0
  14. {pierre_storage-0.10.0 → pierre_storage-0.12.0}/pierre_storage/commit.py +0 -0
  15. {pierre_storage-0.10.0 → pierre_storage-0.12.0}/pierre_storage/errors.py +0 -0
  16. {pierre_storage-0.10.0 → pierre_storage-0.12.0}/pierre_storage/py.typed +0 -0
  17. {pierre_storage-0.10.0 → pierre_storage-0.12.0}/pierre_storage/version.py +0 -0
  18. {pierre_storage-0.10.0 → pierre_storage-0.12.0}/pierre_storage/webhook.py +0 -0
  19. {pierre_storage-0.10.0 → pierre_storage-0.12.0}/pierre_storage.egg-info/SOURCES.txt +0 -0
  20. {pierre_storage-0.10.0 → pierre_storage-0.12.0}/pierre_storage.egg-info/dependency_links.txt +0 -0
  21. {pierre_storage-0.10.0 → pierre_storage-0.12.0}/pierre_storage.egg-info/requires.txt +0 -0
  22. {pierre_storage-0.10.0 → pierre_storage-0.12.0}/pierre_storage.egg-info/top_level.txt +0 -0
  23. {pierre_storage-0.10.0 → pierre_storage-0.12.0}/setup.cfg +0 -0
  24. {pierre_storage-0.10.0 → pierre_storage-0.12.0}/tests/test_commit.py +0 -0
  25. {pierre_storage-0.10.0 → pierre_storage-0.12.0}/tests/test_version.py +0 -0
  26. {pierre_storage-0.10.0 → pierre_storage-0.12.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.10.0
3
+ Version: 0.12.0
4
4
  Summary: Pierre Git Storage SDK for Python
5
5
  Author-email: Pierre <support@pierre.io>
6
6
  License-Expression: MIT
@@ -83,6 +83,15 @@ github_repo = await storage.create_repo(
83
83
  }
84
84
  )
85
85
  # This repository will sync with github.com/octocat/Hello-World
86
+
87
+ # Create a repository by forking an existing repo
88
+ forked_repo = await storage.create_repo(
89
+ id="my-fork",
90
+ base_repo={
91
+ "id": "my-template-id",
92
+ "ref": "main", # optional
93
+ },
94
+ )
86
95
  ```
87
96
 
88
97
  ### Finding a Repository
@@ -149,6 +158,10 @@ repo = await storage.create_repo()
149
158
  # or
150
159
  repo = await storage.find_one(id="existing-repo-id")
151
160
 
161
+ # List repositories for the org
162
+ repos = await storage.list_repos(limit=20)
163
+ print(repos["repos"])
164
+
152
165
  # Get file content (streaming)
153
166
  response = await repo.get_file_stream(
154
167
  path="README.md",
@@ -190,6 +203,27 @@ commits = await repo.list_commits(
190
203
  )
191
204
  print(commits["commits"])
192
205
 
206
+ # Read a git note for a commit
207
+ note = await repo.get_note(sha="abc123...")
208
+ print(note["note"])
209
+
210
+ # Add a git note
211
+ note_result = await repo.create_note(
212
+ sha="abc123...",
213
+ note="Release QA approved",
214
+ author={"name": "Release Bot", "email": "release@example.com"},
215
+ )
216
+ print(note_result["new_ref_sha"])
217
+
218
+ # Append to a git note
219
+ await repo.append_note(
220
+ sha="abc123...",
221
+ note="Follow-up review complete",
222
+ )
223
+
224
+ # Delete a git note
225
+ await repo.delete_note(sha="abc123...")
226
+
193
227
  # Get branch diff
194
228
  branch_diff = await repo.get_branch_diff(
195
229
  branch="feature-branch",
@@ -447,6 +481,23 @@ commits = await repo.list_commits()
447
481
  3. You can then use all Pierre SDK features (diffs, commits, file access) on the synced content
448
482
  4. The provider is automatically set to `"github"` when using `base_repo`
449
483
 
484
+ ### Forking Repositories
485
+
486
+ You can fork an existing repository within the same Pierre org:
487
+
488
+ ```python
489
+ forked_repo = await storage.create_repo(
490
+ id="my-fork",
491
+ base_repo={
492
+ "id": "my-template-id",
493
+ "ref": "main", # optional (branch/tag)
494
+ # "sha": "abc123..." # optional commit SHA (overrides ref)
495
+ },
496
+ )
497
+ ```
498
+
499
+ When `default_branch` is omitted, the SDK returns `"main"`.
500
+
450
501
  ### Restoring Commits
451
502
 
452
503
  You can restore a repository to a previous commit:
@@ -475,7 +526,7 @@ class GitStorage:
475
526
  self,
476
527
  *,
477
528
  id: Optional[str] = None,
478
- default_branch: str = "main",
529
+ default_branch: Optional[str] = None, # defaults to "main"
479
530
  base_repo: Optional[BaseRepo] = None,
480
531
  ttl: Optional[int] = None,
481
532
  ) -> Repo: ...
@@ -614,6 +665,8 @@ Key types are provided via TypedDict for better IDE support:
614
665
  from pierre_storage.types import (
615
666
  GitStorageOptions,
616
667
  BaseRepo,
668
+ GitHubBaseRepo,
669
+ ForkBaseRepo,
617
670
  CommitSignature,
618
671
  CreateCommitOptions,
619
672
  ListFilesResult,
@@ -627,12 +680,19 @@ from pierre_storage.types import (
627
680
  # ... and more
628
681
  )
629
682
 
630
- # BaseRepo type for GitHub sync
631
- class BaseRepo(TypedDict, total=False):
632
- provider: Literal["github"] # Always "github"
683
+ # BaseRepo type for GitHub sync or forks
684
+ class GitHubBaseRepo(TypedDict, total=False):
685
+ provider: Literal["github"] # Always "github"
633
686
  owner: str # GitHub organization or user
634
687
  name: str # Repository name
635
688
  default_branch: Optional[str] # Default branch (optional)
689
+
690
+ class ForkBaseRepo(TypedDict, total=False):
691
+ id: str # Source repo ID
692
+ ref: Optional[str] # Optional ref name
693
+ sha: Optional[str] # Optional commit SHA
694
+
695
+ BaseRepo = Union[GitHubBaseRepo, ForkBaseRepo]
636
696
  ```
637
697
 
638
698
  ## Webhook Validation
@@ -47,6 +47,15 @@ github_repo = await storage.create_repo(
47
47
  }
48
48
  )
49
49
  # This repository will sync with github.com/octocat/Hello-World
50
+
51
+ # Create a repository by forking an existing repo
52
+ forked_repo = await storage.create_repo(
53
+ id="my-fork",
54
+ base_repo={
55
+ "id": "my-template-id",
56
+ "ref": "main", # optional
57
+ },
58
+ )
50
59
  ```
51
60
 
52
61
  ### Finding a Repository
@@ -113,6 +122,10 @@ repo = await storage.create_repo()
113
122
  # or
114
123
  repo = await storage.find_one(id="existing-repo-id")
115
124
 
125
+ # List repositories for the org
126
+ repos = await storage.list_repos(limit=20)
127
+ print(repos["repos"])
128
+
116
129
  # Get file content (streaming)
117
130
  response = await repo.get_file_stream(
118
131
  path="README.md",
@@ -154,6 +167,27 @@ commits = await repo.list_commits(
154
167
  )
155
168
  print(commits["commits"])
156
169
 
170
+ # Read a git note for a commit
171
+ note = await repo.get_note(sha="abc123...")
172
+ print(note["note"])
173
+
174
+ # Add a git note
175
+ note_result = await repo.create_note(
176
+ sha="abc123...",
177
+ note="Release QA approved",
178
+ author={"name": "Release Bot", "email": "release@example.com"},
179
+ )
180
+ print(note_result["new_ref_sha"])
181
+
182
+ # Append to a git note
183
+ await repo.append_note(
184
+ sha="abc123...",
185
+ note="Follow-up review complete",
186
+ )
187
+
188
+ # Delete a git note
189
+ await repo.delete_note(sha="abc123...")
190
+
157
191
  # Get branch diff
158
192
  branch_diff = await repo.get_branch_diff(
159
193
  branch="feature-branch",
@@ -411,6 +445,23 @@ commits = await repo.list_commits()
411
445
  3. You can then use all Pierre SDK features (diffs, commits, file access) on the synced content
412
446
  4. The provider is automatically set to `"github"` when using `base_repo`
413
447
 
448
+ ### Forking Repositories
449
+
450
+ You can fork an existing repository within the same Pierre org:
451
+
452
+ ```python
453
+ forked_repo = await storage.create_repo(
454
+ id="my-fork",
455
+ base_repo={
456
+ "id": "my-template-id",
457
+ "ref": "main", # optional (branch/tag)
458
+ # "sha": "abc123..." # optional commit SHA (overrides ref)
459
+ },
460
+ )
461
+ ```
462
+
463
+ When `default_branch` is omitted, the SDK returns `"main"`.
464
+
414
465
  ### Restoring Commits
415
466
 
416
467
  You can restore a repository to a previous commit:
@@ -439,7 +490,7 @@ class GitStorage:
439
490
  self,
440
491
  *,
441
492
  id: Optional[str] = None,
442
- default_branch: str = "main",
493
+ default_branch: Optional[str] = None, # defaults to "main"
443
494
  base_repo: Optional[BaseRepo] = None,
444
495
  ttl: Optional[int] = None,
445
496
  ) -> Repo: ...
@@ -578,6 +629,8 @@ Key types are provided via TypedDict for better IDE support:
578
629
  from pierre_storage.types import (
579
630
  GitStorageOptions,
580
631
  BaseRepo,
632
+ GitHubBaseRepo,
633
+ ForkBaseRepo,
581
634
  CommitSignature,
582
635
  CreateCommitOptions,
583
636
  ListFilesResult,
@@ -591,12 +644,19 @@ from pierre_storage.types import (
591
644
  # ... and more
592
645
  )
593
646
 
594
- # BaseRepo type for GitHub sync
595
- class BaseRepo(TypedDict, total=False):
596
- provider: Literal["github"] # Always "github"
647
+ # BaseRepo type for GitHub sync or forks
648
+ class GitHubBaseRepo(TypedDict, total=False):
649
+ provider: Literal["github"] # Always "github"
597
650
  owner: str # GitHub organization or user
598
651
  name: str # Repository name
599
652
  default_branch: Optional[str] # Default branch (optional)
653
+
654
+ class ForkBaseRepo(TypedDict, total=False):
655
+ id: str # Source repo ID
656
+ ref: Optional[str] # Optional ref name
657
+ sha: Optional[str] # Optional commit SHA
658
+
659
+ BaseRepo = Union[GitHubBaseRepo, ForkBaseRepo]
600
660
  ```
601
661
 
602
662
  ## Webhook Validation
@@ -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
@@ -1,7 +1,8 @@
1
1
  """Main client for Pierre Git Storage SDK."""
2
2
 
3
3
  import uuid
4
- from typing import Any, Dict, Optional
4
+ from typing import Any, Dict, Optional, cast
5
+ from urllib.parse import urlencode
5
6
 
6
7
  import httpx
7
8
 
@@ -9,9 +10,14 @@ from pierre_storage.auth import generate_jwt
9
10
  from pierre_storage.errors import ApiError
10
11
  from pierre_storage.repo import DEFAULT_TOKEN_TTL_SECONDS, RepoImpl
11
12
  from pierre_storage.types import (
13
+ BaseRepo,
12
14
  DeleteRepoResult,
15
+ ForkBaseRepo,
16
+ GitHubBaseRepo,
13
17
  GitStorageOptions,
18
+ ListReposResult,
14
19
  Repo,
20
+ RepoInfo,
15
21
  )
16
22
  from pierre_storage.version import get_user_agent
17
23
 
@@ -99,17 +105,18 @@ class GitStorage:
99
105
  self,
100
106
  *,
101
107
  id: Optional[str] = None,
102
- default_branch: str = "main",
103
- base_repo: Optional[Dict[str, Any]] = None,
108
+ default_branch: Optional[str] = None,
109
+ base_repo: Optional[BaseRepo] = None,
104
110
  ttl: Optional[int] = None,
105
111
  ) -> Repo:
106
112
  """Create a new repository.
107
113
 
108
114
  Args:
109
115
  id: Repository ID (auto-generated if not provided)
110
- default_branch: Default branch name (default: "main")
111
- base_repo: Optional base repository for GitHub sync
112
- (provider, owner, name, default_branch)
116
+ default_branch: Default branch name (default: "main" for non-forks)
117
+ base_repo: Optional base repository for GitHub sync or fork
118
+ GitHub: owner, name, default_branch
119
+ Fork: id, ref, sha
113
120
  ttl: Token TTL in seconds
114
121
 
115
122
  Returns:
@@ -126,19 +133,51 @@ class GitStorage:
126
133
  )
127
134
 
128
135
  url = f"{self.options['api_base_url']}/api/v{self.options['api_version']}/repos"
129
- body: Dict[str, Any] = {"default_branch": default_branch}
136
+ body: Dict[str, Any] = {}
130
137
 
131
138
  # Match backend priority: base_repo.default_branch > default_branch > 'main'
132
- resolved_default_branch = default_branch
139
+ explicit_default_branch = default_branch is not None
140
+ resolved_default_branch: Optional[str] = None
141
+
133
142
  if base_repo:
134
- # Ensure provider is set to 'github' if not provided
135
- base_repo_with_provider = {
136
- "provider": "github",
137
- **base_repo,
138
- }
139
- body["base_repo"] = base_repo_with_provider
140
- if base_repo.get("default_branch"):
141
- resolved_default_branch = base_repo["default_branch"]
143
+ if "id" in base_repo:
144
+ fork_repo = cast(ForkBaseRepo, base_repo)
145
+ base_repo_token = self._generate_jwt(
146
+ fork_repo["id"],
147
+ {"permissions": ["git:read"], "ttl": ttl},
148
+ )
149
+ base_repo_payload: Dict[str, Any] = {
150
+ "provider": "code",
151
+ "name": fork_repo["id"],
152
+ "operation": "fork",
153
+ "auth": {"token": base_repo_token},
154
+ }
155
+ if fork_repo.get("ref"):
156
+ base_repo_payload["ref"] = fork_repo["ref"]
157
+ if fork_repo.get("sha"):
158
+ base_repo_payload["sha"] = fork_repo["sha"]
159
+ body["base_repo"] = base_repo_payload
160
+ if explicit_default_branch:
161
+ resolved_default_branch = default_branch
162
+ body["default_branch"] = default_branch
163
+ else:
164
+ github_repo = cast(GitHubBaseRepo, base_repo)
165
+ # Ensure provider is set to 'github' if not provided
166
+ base_repo_with_provider = {
167
+ "provider": "github",
168
+ **github_repo,
169
+ }
170
+ body["base_repo"] = base_repo_with_provider
171
+ if github_repo.get("default_branch"):
172
+ resolved_default_branch = github_repo["default_branch"]
173
+ elif explicit_default_branch:
174
+ resolved_default_branch = default_branch
175
+ else:
176
+ resolved_default_branch = "main"
177
+ body["default_branch"] = resolved_default_branch
178
+ else:
179
+ resolved_default_branch = default_branch if explicit_default_branch else "main"
180
+ body["default_branch"] = resolved_default_branch
142
181
 
143
182
  async with httpx.AsyncClient() as client:
144
183
  response = await client.post(
@@ -170,7 +209,7 @@ class GitStorage:
170
209
 
171
210
  return RepoImpl(
172
211
  repo_id,
173
- resolved_default_branch,
212
+ resolved_default_branch or "main",
174
213
  api_base_url,
175
214
  storage_base_url,
176
215
  name,
@@ -178,6 +217,66 @@ class GitStorage:
178
217
  self._generate_jwt,
179
218
  )
180
219
 
220
+ async def list_repos(
221
+ self,
222
+ *,
223
+ cursor: Optional[str] = None,
224
+ limit: Optional[int] = None,
225
+ ttl: Optional[int] = None,
226
+ ) -> ListReposResult:
227
+ """List repositories for the organization."""
228
+ ttl = ttl or DEFAULT_TOKEN_TTL_SECONDS
229
+ jwt = self._generate_jwt(
230
+ "org",
231
+ {"permissions": ["org:read"], "ttl": ttl},
232
+ )
233
+
234
+ params: Dict[str, str] = {}
235
+ if cursor:
236
+ params["cursor"] = cursor
237
+ if limit is not None:
238
+ params["limit"] = str(limit)
239
+
240
+ url = f"{self.options['api_base_url']}/api/v{self.options['api_version']}/repos"
241
+ if params:
242
+ url += f"?{urlencode(params)}"
243
+
244
+ async with httpx.AsyncClient() as client:
245
+ response = await client.get(
246
+ url,
247
+ headers={
248
+ "Authorization": f"Bearer {jwt}",
249
+ "Code-Storage-Agent": get_user_agent(),
250
+ },
251
+ timeout=30.0,
252
+ )
253
+
254
+ if not response.is_success:
255
+ raise ApiError(
256
+ f"Failed to list repositories: {response.status_code} {response.reason_phrase}",
257
+ status_code=response.status_code,
258
+ response=response,
259
+ )
260
+
261
+ data = response.json()
262
+ repos: list[RepoInfo] = []
263
+ for repo in data.get("repos", []):
264
+ entry: RepoInfo = {
265
+ "repo_id": repo.get("repo_id", ""),
266
+ "url": repo.get("url", ""),
267
+ "default_branch": repo.get("default_branch", "main"),
268
+ "created_at": repo.get("created_at", ""),
269
+ }
270
+ if repo.get("base_repo"):
271
+ entry["base_repo"] = repo.get("base_repo")
272
+ repos.append(entry)
273
+
274
+ return {
275
+ "repos": repos,
276
+ "next_cursor": data.get("next_cursor"),
277
+ "has_more": data.get("has_more", False),
278
+ }
279
+
181
280
  async def find_one(self, *, id: str) -> Optional[Repo]:
182
281
  """Find a repository by ID.
183
282
 
@@ -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 {
@@ -41,7 +41,7 @@ class GitStorageOptions(TypedDict, total=False):
41
41
  default_ttl: Optional[int]
42
42
 
43
43
 
44
- class BaseRepo(TypedDict, total=False):
44
+ class GitHubBaseRepo(TypedDict, total=False):
45
45
  """Base repository configuration for GitHub sync."""
46
46
 
47
47
  provider: Literal["github"] # required
@@ -50,6 +50,17 @@ class BaseRepo(TypedDict, total=False):
50
50
  default_branch: Optional[str]
51
51
 
52
52
 
53
+ class ForkBaseRepo(TypedDict, total=False):
54
+ """Base repository configuration for code storage forks."""
55
+
56
+ id: str # required
57
+ ref: Optional[str]
58
+ sha: Optional[str]
59
+
60
+
61
+ BaseRepo = Union[GitHubBaseRepo, ForkBaseRepo]
62
+
63
+
53
64
  class DeleteRepoResult(TypedDict):
54
65
  """Result from deleting a repository."""
55
66
 
@@ -57,6 +68,33 @@ class DeleteRepoResult(TypedDict):
57
68
  message: str
58
69
 
59
70
 
71
+ # Repository list types
72
+ class RepoBaseInfo(TypedDict):
73
+ """Base repository info for listed repositories."""
74
+
75
+ provider: str
76
+ owner: str
77
+ name: str
78
+
79
+
80
+ class RepoInfo(TypedDict, total=False):
81
+ """Repository info in list responses."""
82
+
83
+ repo_id: str
84
+ url: str
85
+ default_branch: str
86
+ created_at: str
87
+ base_repo: NotRequired[RepoBaseInfo]
88
+
89
+
90
+ class ListReposResult(TypedDict):
91
+ """Result from listing repositories."""
92
+
93
+ repos: List[RepoInfo]
94
+ next_cursor: Optional[str]
95
+ has_more: bool
96
+
97
+
60
98
  # Removed: GetRemoteURLOptions - now uses **kwargs
61
99
  # Removed: CreateRepoOptions - now uses **kwargs
62
100
  # Removed: FindOneOptions - now uses **kwargs
@@ -127,6 +165,33 @@ class ListCommitsResult(TypedDict):
127
165
  has_more: bool
128
166
 
129
167
 
168
+ # Git notes types
169
+ class NoteReadResult(TypedDict):
170
+ """Result from reading a git note."""
171
+
172
+ sha: str
173
+ note: str
174
+ ref_sha: str
175
+
176
+
177
+ class NoteWriteResultPayload(TypedDict):
178
+ """Result payload for note writes."""
179
+
180
+ success: bool
181
+ status: str
182
+ message: NotRequired[str]
183
+
184
+
185
+ class NoteWriteResult(TypedDict):
186
+ """Result from writing a git note."""
187
+
188
+ sha: str
189
+ target_ref: str
190
+ base_commit: NotRequired[str]
191
+ new_ref_sha: str
192
+ result: NoteWriteResultPayload
193
+
194
+
130
195
  # Diff types
131
196
  class DiffStats(TypedDict):
132
197
  """Statistics about a diff."""
@@ -417,6 +482,50 @@ class Repo(Protocol):
417
482
  """List commits in the repository."""
418
483
  ...
419
484
 
485
+ async def get_note(
486
+ self,
487
+ *,
488
+ sha: str,
489
+ ttl: Optional[int] = None,
490
+ ) -> NoteReadResult:
491
+ """Read a git note."""
492
+ ...
493
+
494
+ async def create_note(
495
+ self,
496
+ *,
497
+ sha: str,
498
+ note: str,
499
+ expected_ref_sha: Optional[str] = None,
500
+ author: Optional["CommitSignature"] = None,
501
+ ttl: Optional[int] = None,
502
+ ) -> NoteWriteResult:
503
+ """Create a git note."""
504
+ ...
505
+
506
+ async def append_note(
507
+ self,
508
+ *,
509
+ sha: str,
510
+ note: str,
511
+ expected_ref_sha: Optional[str] = None,
512
+ author: Optional["CommitSignature"] = None,
513
+ ttl: Optional[int] = None,
514
+ ) -> NoteWriteResult:
515
+ """Append to a git note."""
516
+ ...
517
+
518
+ async def delete_note(
519
+ self,
520
+ *,
521
+ sha: str,
522
+ expected_ref_sha: Optional[str] = None,
523
+ author: Optional["CommitSignature"] = None,
524
+ ttl: Optional[int] = None,
525
+ ) -> NoteWriteResult:
526
+ """Delete a git note."""
527
+ ...
528
+
420
529
  async def get_branch_diff(
421
530
  self,
422
531
  *,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pierre-storage
3
- Version: 0.10.0
3
+ Version: 0.12.0
4
4
  Summary: Pierre Git Storage SDK for Python
5
5
  Author-email: Pierre <support@pierre.io>
6
6
  License-Expression: MIT
@@ -83,6 +83,15 @@ github_repo = await storage.create_repo(
83
83
  }
84
84
  )
85
85
  # This repository will sync with github.com/octocat/Hello-World
86
+
87
+ # Create a repository by forking an existing repo
88
+ forked_repo = await storage.create_repo(
89
+ id="my-fork",
90
+ base_repo={
91
+ "id": "my-template-id",
92
+ "ref": "main", # optional
93
+ },
94
+ )
86
95
  ```
87
96
 
88
97
  ### Finding a Repository
@@ -149,6 +158,10 @@ repo = await storage.create_repo()
149
158
  # or
150
159
  repo = await storage.find_one(id="existing-repo-id")
151
160
 
161
+ # List repositories for the org
162
+ repos = await storage.list_repos(limit=20)
163
+ print(repos["repos"])
164
+
152
165
  # Get file content (streaming)
153
166
  response = await repo.get_file_stream(
154
167
  path="README.md",
@@ -190,6 +203,27 @@ commits = await repo.list_commits(
190
203
  )
191
204
  print(commits["commits"])
192
205
 
206
+ # Read a git note for a commit
207
+ note = await repo.get_note(sha="abc123...")
208
+ print(note["note"])
209
+
210
+ # Add a git note
211
+ note_result = await repo.create_note(
212
+ sha="abc123...",
213
+ note="Release QA approved",
214
+ author={"name": "Release Bot", "email": "release@example.com"},
215
+ )
216
+ print(note_result["new_ref_sha"])
217
+
218
+ # Append to a git note
219
+ await repo.append_note(
220
+ sha="abc123...",
221
+ note="Follow-up review complete",
222
+ )
223
+
224
+ # Delete a git note
225
+ await repo.delete_note(sha="abc123...")
226
+
193
227
  # Get branch diff
194
228
  branch_diff = await repo.get_branch_diff(
195
229
  branch="feature-branch",
@@ -447,6 +481,23 @@ commits = await repo.list_commits()
447
481
  3. You can then use all Pierre SDK features (diffs, commits, file access) on the synced content
448
482
  4. The provider is automatically set to `"github"` when using `base_repo`
449
483
 
484
+ ### Forking Repositories
485
+
486
+ You can fork an existing repository within the same Pierre org:
487
+
488
+ ```python
489
+ forked_repo = await storage.create_repo(
490
+ id="my-fork",
491
+ base_repo={
492
+ "id": "my-template-id",
493
+ "ref": "main", # optional (branch/tag)
494
+ # "sha": "abc123..." # optional commit SHA (overrides ref)
495
+ },
496
+ )
497
+ ```
498
+
499
+ When `default_branch` is omitted, the SDK returns `"main"`.
500
+
450
501
  ### Restoring Commits
451
502
 
452
503
  You can restore a repository to a previous commit:
@@ -475,7 +526,7 @@ class GitStorage:
475
526
  self,
476
527
  *,
477
528
  id: Optional[str] = None,
478
- default_branch: str = "main",
529
+ default_branch: Optional[str] = None, # defaults to "main"
479
530
  base_repo: Optional[BaseRepo] = None,
480
531
  ttl: Optional[int] = None,
481
532
  ) -> Repo: ...
@@ -614,6 +665,8 @@ Key types are provided via TypedDict for better IDE support:
614
665
  from pierre_storage.types import (
615
666
  GitStorageOptions,
616
667
  BaseRepo,
668
+ GitHubBaseRepo,
669
+ ForkBaseRepo,
617
670
  CommitSignature,
618
671
  CreateCommitOptions,
619
672
  ListFilesResult,
@@ -627,12 +680,19 @@ from pierre_storage.types import (
627
680
  # ... and more
628
681
  )
629
682
 
630
- # BaseRepo type for GitHub sync
631
- class BaseRepo(TypedDict, total=False):
632
- provider: Literal["github"] # Always "github"
683
+ # BaseRepo type for GitHub sync or forks
684
+ class GitHubBaseRepo(TypedDict, total=False):
685
+ provider: Literal["github"] # Always "github"
633
686
  owner: str # GitHub organization or user
634
687
  name: str # Repository name
635
688
  default_branch: Optional[str] # Default branch (optional)
689
+
690
+ class ForkBaseRepo(TypedDict, total=False):
691
+ id: str # Source repo ID
692
+ ref: Optional[str] # Optional ref name
693
+ sha: Optional[str] # Optional commit SHA
694
+
695
+ BaseRepo = Union[GitHubBaseRepo, ForkBaseRepo]
636
696
  ```
637
697
 
638
698
  ## Webhook Validation
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "pierre-storage"
7
- version = "0.10.0"
7
+ version = "0.12.0"
8
8
  description = "Pierre Git Storage SDK for Python"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -147,6 +147,42 @@ class TestGitStorage:
147
147
  body = call_kwargs["json"]
148
148
  assert body["base_repo"]["provider"] == "github"
149
149
 
150
+ @pytest.mark.asyncio
151
+ async def test_create_repo_with_fork_base_repo(self, git_storage_options: dict) -> None:
152
+ """Test creating a forked repository."""
153
+ storage = GitStorage(git_storage_options)
154
+
155
+ mock_post_response = MagicMock()
156
+ mock_post_response.status_code = 200
157
+ mock_post_response.is_success = True
158
+ mock_post_response.json.return_value = {"repo_id": "test-repo"}
159
+
160
+ with patch("httpx.AsyncClient") as mock_client:
161
+ client_instance = mock_client.return_value.__aenter__.return_value
162
+ client_instance.post = AsyncMock(return_value=mock_post_response)
163
+
164
+ repo = await storage.create_repo(
165
+ id="test-repo",
166
+ base_repo={
167
+ "id": "template-repo",
168
+ "ref": "develop",
169
+ },
170
+ )
171
+ assert repo.default_branch == "main"
172
+
173
+ call_kwargs = client_instance.post.call_args[1]
174
+ body = call_kwargs["json"]
175
+ assert "default_branch" not in body
176
+ assert body["base_repo"]["provider"] == "code"
177
+ assert body["base_repo"]["name"] == "template-repo"
178
+ assert body["base_repo"]["operation"] == "fork"
179
+ assert body["base_repo"]["ref"] == "develop"
180
+
181
+ token = body["base_repo"]["auth"]["token"]
182
+ payload = jwt.decode(token, options={"verify_signature": False})
183
+ assert payload["repo"] == "template-repo"
184
+ assert payload["scopes"] == ["git:read"]
185
+
150
186
  @pytest.mark.asyncio
151
187
  async def test_create_repo_conflict(self, git_storage_options: dict) -> None:
152
188
  """Test creating a repository that already exists."""
@@ -164,6 +200,68 @@ class TestGitStorage:
164
200
  with pytest.raises(ApiError, match="Repository already exists"):
165
201
  await storage.create_repo(id="existing-repo")
166
202
 
203
+ @pytest.mark.asyncio
204
+ async def test_list_repos(self, git_storage_options: dict) -> None:
205
+ """Test listing repositories."""
206
+ storage = GitStorage(git_storage_options)
207
+
208
+ mock_response = MagicMock()
209
+ mock_response.status_code = 200
210
+ mock_response.is_success = True
211
+ mock_response.json.return_value = {
212
+ "repos": [
213
+ {
214
+ "repo_id": "repo-1",
215
+ "url": "owner/repo-1",
216
+ "default_branch": "main",
217
+ "created_at": "2024-01-01T00:00:00Z",
218
+ "base_repo": {"provider": "github", "owner": "owner", "name": "repo-1"},
219
+ }
220
+ ],
221
+ "next_cursor": None,
222
+ "has_more": False,
223
+ }
224
+
225
+ with patch("httpx.AsyncClient") as mock_client:
226
+ mock_get = AsyncMock(return_value=mock_response)
227
+ mock_client.return_value.__aenter__.return_value.get = mock_get
228
+
229
+ result = await storage.list_repos()
230
+ assert result["has_more"] is False
231
+ assert result["repos"][0]["repo_id"] == "repo-1"
232
+
233
+ call_kwargs = mock_get.call_args[1]
234
+ headers = call_kwargs["headers"]
235
+ token = headers["Authorization"].replace("Bearer ", "")
236
+ payload = jwt.decode(token, options={"verify_signature": False})
237
+ assert payload["scopes"] == ["org:read"]
238
+ assert payload["repo"] == "org"
239
+
240
+ @pytest.mark.asyncio
241
+ async def test_list_repos_with_cursor(self, git_storage_options: dict) -> None:
242
+ """Test listing repositories with pagination."""
243
+ storage = GitStorage(git_storage_options)
244
+
245
+ mock_response = MagicMock()
246
+ mock_response.status_code = 200
247
+ mock_response.is_success = True
248
+ mock_response.json.return_value = {
249
+ "repos": [],
250
+ "next_cursor": "next",
251
+ "has_more": True,
252
+ }
253
+
254
+ with patch("httpx.AsyncClient") as mock_client:
255
+ mock_get = AsyncMock(return_value=mock_response)
256
+ mock_client.return_value.__aenter__.return_value.get = mock_get
257
+
258
+ await storage.list_repos(cursor="cursor-1", limit=10)
259
+
260
+ call_args = mock_get.call_args[0]
261
+ api_url = call_args[0]
262
+ assert "cursor=cursor-1" in api_url
263
+ assert "limit=10" in api_url
264
+
167
265
  @pytest.mark.asyncio
168
266
  async def test_find_one(self, git_storage_options: dict) -> None:
169
267
  """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