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.
- {pierre_storage-0.10.0/pierre_storage.egg-info → pierre_storage-0.12.0}/PKG-INFO +65 -5
- {pierre_storage-0.10.0 → pierre_storage-0.12.0}/README.md +64 -4
- {pierre_storage-0.10.0 → pierre_storage-0.12.0}/pierre_storage/__init__.py +8 -0
- {pierre_storage-0.10.0 → pierre_storage-0.12.0}/pierre_storage/client.py +116 -17
- {pierre_storage-0.10.0 → pierre_storage-0.12.0}/pierre_storage/repo.py +235 -0
- {pierre_storage-0.10.0 → pierre_storage-0.12.0}/pierre_storage/types.py +110 -1
- {pierre_storage-0.10.0 → pierre_storage-0.12.0/pierre_storage.egg-info}/PKG-INFO +65 -5
- {pierre_storage-0.10.0 → pierre_storage-0.12.0}/pyproject.toml +1 -1
- {pierre_storage-0.10.0 → pierre_storage-0.12.0}/tests/test_client.py +98 -0
- {pierre_storage-0.10.0 → pierre_storage-0.12.0}/tests/test_repo.py +112 -0
- {pierre_storage-0.10.0 → pierre_storage-0.12.0}/LICENSE +0 -0
- {pierre_storage-0.10.0 → pierre_storage-0.12.0}/MANIFEST.in +0 -0
- {pierre_storage-0.10.0 → pierre_storage-0.12.0}/pierre_storage/auth.py +0 -0
- {pierre_storage-0.10.0 → pierre_storage-0.12.0}/pierre_storage/commit.py +0 -0
- {pierre_storage-0.10.0 → pierre_storage-0.12.0}/pierre_storage/errors.py +0 -0
- {pierre_storage-0.10.0 → pierre_storage-0.12.0}/pierre_storage/py.typed +0 -0
- {pierre_storage-0.10.0 → pierre_storage-0.12.0}/pierre_storage/version.py +0 -0
- {pierre_storage-0.10.0 → pierre_storage-0.12.0}/pierre_storage/webhook.py +0 -0
- {pierre_storage-0.10.0 → pierre_storage-0.12.0}/pierre_storage.egg-info/SOURCES.txt +0 -0
- {pierre_storage-0.10.0 → pierre_storage-0.12.0}/pierre_storage.egg-info/dependency_links.txt +0 -0
- {pierre_storage-0.10.0 → pierre_storage-0.12.0}/pierre_storage.egg-info/requires.txt +0 -0
- {pierre_storage-0.10.0 → pierre_storage-0.12.0}/pierre_storage.egg-info/top_level.txt +0 -0
- {pierre_storage-0.10.0 → pierre_storage-0.12.0}/setup.cfg +0 -0
- {pierre_storage-0.10.0 → pierre_storage-0.12.0}/tests/test_commit.py +0 -0
- {pierre_storage-0.10.0 → pierre_storage-0.12.0}/tests/test_version.py +0 -0
- {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.
|
|
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
|
|
632
|
-
provider: Literal["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
|
|
596
|
-
provider: Literal["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 =
|
|
103
|
-
base_repo: Optional[
|
|
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
|
-
|
|
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] = {
|
|
136
|
+
body: Dict[str, Any] = {}
|
|
130
137
|
|
|
131
138
|
# Match backend priority: base_repo.default_branch > default_branch > 'main'
|
|
132
|
-
|
|
139
|
+
explicit_default_branch = default_branch is not None
|
|
140
|
+
resolved_default_branch: Optional[str] = None
|
|
141
|
+
|
|
133
142
|
if base_repo:
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
|
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.
|
|
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
|
|
632
|
-
provider: Literal["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
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{pierre_storage-0.10.0 → pierre_storage-0.12.0}/pierre_storage.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|