Skip to content

Commit 2c22b40

Browse files
authored
feat: expose commit signature and payload on getCommit (#32)
1 parent 9d32661 commit 2c22b40

21 files changed

Lines changed: 308 additions & 30 deletions

File tree

packages/code-storage-go/repo.go

Lines changed: 19 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -554,18 +554,25 @@ func (r *Repo) GetCommit(ctx context.Context, options GetCommitOptions) (GetComm
554554
return GetCommitResult{}, err
555555
}
556556

557-
return GetCommitResult{
558-
Commit: CommitInfo{
559-
SHA: payload.Commit.SHA,
560-
Message: payload.Commit.Message,
561-
AuthorName: payload.Commit.AuthorName,
562-
AuthorEmail: payload.Commit.AuthorEmail,
563-
CommitterName: payload.Commit.CommitterName,
564-
CommitterEmail: payload.Commit.CommitterEmail,
565-
Date: parseTime(payload.Commit.Date),
566-
RawDate: payload.Commit.Date,
567-
},
568-
}, nil
557+
commit := CommitInfo{
558+
SHA: payload.Commit.SHA,
559+
Message: payload.Commit.Message,
560+
AuthorName: payload.Commit.AuthorName,
561+
AuthorEmail: payload.Commit.AuthorEmail,
562+
CommitterName: payload.Commit.CommitterName,
563+
CommitterEmail: payload.Commit.CommitterEmail,
564+
Date: parseTime(payload.Commit.Date),
565+
RawDate: payload.Commit.Date,
566+
}
567+
// Only surface these for signed commits, which carry both the armored
568+
// signature and the signed payload. If either is missing the commit is
569+
// treated as unsigned and neither field is set.
570+
if payload.Commit.Signature != "" && payload.Commit.Payload != "" {
571+
commit.Signature = payload.Commit.Signature
572+
commit.Payload = payload.Commit.Payload
573+
}
574+
575+
return GetCommitResult{Commit: commit}, nil
569576
}
570577

571578
// GetBlame returns per-line authorship for a file at a ref.

packages/code-storage-go/repo_test.go

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,30 @@ func TestRemoteURLRefs(t *testing.T) {
142142
}
143143
})
144144

145+
t.Run("includes verify-sig op in refs claim", func(t *testing.T) {
146+
remote, err := repo.RemoteURL(nil, RemoteURLOptions{
147+
RefPolicies: RefPolicyList{
148+
{Pattern: "refs/heads/main", Ops: Ops{OpVerifySig}},
149+
},
150+
})
151+
if err != nil {
152+
t.Fatalf("remote url error: %v", err)
153+
}
154+
claims := parseJWTFromURL(t, remote)
155+
refs, ok := claims["refs"].([]interface{})
156+
if !ok || len(refs) != 1 {
157+
t.Fatalf("expected 1 ref rule, got %v", claims["refs"])
158+
}
159+
rule, ok := refs[0].([]interface{})
160+
if !ok || len(rule) != 2 {
161+
t.Fatalf("unexpected rule shape: %v", refs[0])
162+
}
163+
ops, ok := rule[1].([]interface{})
164+
if !ok || len(ops) != 1 || ops[0] != "verify-sig" {
165+
t.Fatalf("unexpected ops: %v", rule[1])
166+
}
167+
})
168+
145169
t.Run("omits refs from JWT when not provided", func(t *testing.T) {
146170
remote, err := repo.RemoteURL(nil, RemoteURLOptions{})
147171
if err != nil {
@@ -1938,7 +1962,7 @@ func TestGetCommit(t *testing.T) {
19381962
t.Fatalf("unexpected sha query: %q", got)
19391963
}
19401964
w.Header().Set("Content-Type", "application/json")
1941-
_, _ = w.Write([]byte(`{"commit":{"sha":"abc123","message":"feat: add endpoint","author_name":"Jane Doe","author_email":"jane@example.com","committer_name":"Jane Doe","committer_email":"jane@example.com","date":"2024-01-15T14:32:18Z"}}`))
1965+
_, _ = w.Write([]byte(`{"commit":{"sha":"abc123","message":"feat: add endpoint","author_name":"Jane Doe","author_email":"jane@example.com","committer_name":"Jane Doe","committer_email":"jane@example.com","date":"2024-01-15T14:32:18Z","signature":"-----BEGIN PGP SIGNATURE-----\nABC\n-----END PGP SIGNATURE-----\n","payload":"tree deadbeef\nauthor Jane Doe <jane@example.com> 1700000000 +0000\n"}}`))
19421966
}))
19431967
defer server.Close()
19441968

@@ -1964,6 +1988,34 @@ func TestGetCommit(t *testing.T) {
19641988
if result.Commit.RawDate != "2024-01-15T14:32:18Z" || result.Commit.Date.IsZero() {
19651989
t.Fatalf("unexpected date: %+v", result.Commit)
19661990
}
1991+
if !strings.Contains(result.Commit.Signature, "BEGIN PGP SIGNATURE") {
1992+
t.Fatalf("unexpected signature: %q", result.Commit.Signature)
1993+
}
1994+
if !strings.HasPrefix(result.Commit.Payload, "tree deadbeef") {
1995+
t.Fatalf("unexpected payload: %q", result.Commit.Payload)
1996+
}
1997+
}
1998+
1999+
func TestGetCommitUnsigned(t *testing.T) {
2000+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
2001+
w.Header().Set("Content-Type", "application/json")
2002+
_, _ = w.Write([]byte(`{"commit":{"sha":"abc123","message":"chore: noop","author_name":"Jane Doe","author_email":"jane@example.com","committer_name":"Jane Doe","committer_email":"jane@example.com","date":"2024-01-15T14:32:18Z"}}`))
2003+
}))
2004+
defer server.Close()
2005+
2006+
client, err := NewClient(Options{Name: "acme", Key: testKey, APIBaseURL: server.URL})
2007+
if err != nil {
2008+
t.Fatalf("client error: %v", err)
2009+
}
2010+
repo := &Repo{ID: "repo", DefaultBranch: "main", client: client}
2011+
2012+
result, err := repo.GetCommit(nil, GetCommitOptions{SHA: "abc123"})
2013+
if err != nil {
2014+
t.Fatalf("get commit error: %v", err)
2015+
}
2016+
if result.Commit.Signature != "" || result.Commit.Payload != "" {
2017+
t.Fatalf("expected empty signature/payload for unsigned commit, got %+v", result.Commit)
2018+
}
19672019
}
19682020

19692021
func TestGetCommitRequiresSHA(t *testing.T) {

packages/code-storage-go/responses.go

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ type fileWithMetadataRaw struct {
2727
Mode string `json:"mode"`
2828
Size int64 `json:"size"`
2929
LastCommitSHA string `json:"last_commit_sha"`
30-
Type string `json:"type,omitempty"`
30+
Type string `json:"type"`
3131
}
3232

3333
type commitMetadataRaw struct {
@@ -56,7 +56,7 @@ type listCommitsResponse struct {
5656
}
5757

5858
type getCommitResponse struct {
59-
Commit commitInfoRaw `json:"commit"`
59+
Commit commitInfoWithSignatureRaw `json:"commit"`
6060
}
6161

6262
type blameResponse struct {
@@ -71,7 +71,7 @@ type blameLineRaw struct {
7171
CommitSHA string `json:"commit_sha"`
7272
OriginalLineNumber int32 `json:"original_line_number"`
7373
OriginalPath string `json:"original_path"`
74-
PreviousCommitSHA string `json:"previous_commit_sha,omitempty"`
74+
PreviousCommitSHA string `json:"previous_commit_sha"`
7575
AuthorName string `json:"author_name"`
7676
AuthorEmail string `json:"author_email"`
7777
AuthorTime string `json:"author_time"`
@@ -91,6 +91,15 @@ type commitInfoRaw struct {
9191
Date string `json:"date"`
9292
}
9393

94+
// commitInfoWithSignatureRaw extends commitInfoRaw with the signature details
95+
// the single-commit endpoint returns for signed commits. Both fields are
96+
// omitted for unsigned commits.
97+
type commitInfoWithSignatureRaw struct {
98+
commitInfoRaw
99+
Signature string `json:"signature"`
100+
Payload string `json:"payload"`
101+
}
102+
94103
type listReposResponse struct {
95104
Repos []repoInfoRaw `json:"repos"`
96105
NextCursor string `json:"next_cursor"`
@@ -185,7 +194,7 @@ type mergeResponse struct {
185194
TreeSHA string `json:"tree_sha"`
186195
Source mergeSourceRaw `json:"source"`
187196
Target mergeTargetRaw `json:"target"`
188-
MergeBaseSHA string `json:"merge_base_sha,omitempty"`
197+
MergeBaseSHA string `json:"merge_base_sha"`
189198
PromotedCommits int `json:"promoted_commits"`
190199
}
191200

packages/code-storage-go/types.go

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@ type Op = string
3636
const (
3737
OpNoForcePush Op = "no-force-push"
3838
OpNoPush Op = "no-push"
39+
// OpVerifySig requires every commit introduced by a push to a matching ref
40+
// to carry a valid signature from a registered signing key.
41+
OpVerifySig Op = "verify-sig"
3942
)
4043

4144
// Ops is a list of policy operations.
@@ -551,6 +554,15 @@ type CommitInfo struct {
551554
CommitterEmail string
552555
Date time.Time
553556
RawDate string
557+
// Signature is the armored OpenPGP/SSH signature from the commit's gpgsig
558+
// header. Only populated by GetCommit for signed commits. Always empty for
559+
// ListCommits entries and unsigned commits.
560+
Signature string
561+
// Payload is the exact bytes the signature is computed over (the raw commit
562+
// object with the gpgsig header removed). Only populated by GetCommit for
563+
// signed commits. Always empty for ListCommits entries and unsigned
564+
// commits.
565+
Payload string
554566
}
555567

556568
// ListCommitsResult describes commits list.
@@ -566,7 +578,8 @@ type GetCommitOptions struct {
566578
SHA string
567579
}
568580

569-
// GetCommitResult is the result returned by Repo.GetCommit.
581+
// GetCommitResult is the result returned by Repo.GetCommit. For signed commits
582+
// the returned CommitInfo carries the armored Signature and signed Payload.
570583
type GetCommitResult struct {
571584
Commit CommitInfo
572585
}

packages/code-storage-go/version.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ package storage
22

33
const (
44
PackageName = "code-storage-go-sdk"
5-
PackageVersion = "0.9.0"
5+
PackageVersion = "0.10.0"
66
)
77

88
func userAgent() string {

packages/code-storage-python/README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,11 @@ print(commits["commits"])
273273
# Get a single commit's metadata (no diff)
274274
result = await repo.get_commit(sha="abc123...")
275275
print(result["commit"]["message"], result["commit"]["author_name"])
276+
# Signed commits also include the armored signature plus the exact signed
277+
# payload (raw commit object minus the gpgsig header) so you can verify it
278+
# yourself. Both keys are absent for unsigned commits.
279+
if "signature" in result["commit"]:
280+
print(result["commit"]["signature"], result["commit"]["payload"])
276281

277282
# Blame a file (per-line authorship). The top-level "commit_sha" is the SHA the
278283
# `ref` resolved to; each entry in "lines" carries its own author/committer

packages/code-storage-python/pierre_storage/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from pierre_storage.types import (
1010
OP_NO_FORCE_PUSH,
1111
OP_NO_PUSH,
12+
OP_VERIFY_SIG,
1213
BaseRepo,
1314
BlameLine,
1415
BlameResult,
@@ -101,6 +102,7 @@
101102
"Op",
102103
"OP_NO_FORCE_PUSH",
103104
"OP_NO_PUSH",
105+
"OP_VERIFY_SIG",
104106
"Ops",
105107
"RefPolicy",
106108
"Refs",

packages/code-storage-python/pierre_storage/repo.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1390,6 +1390,15 @@ async def get_commit(
13901390
"date": date,
13911391
"raw_date": commit_raw["date"],
13921392
}
1393+
# Only surface these for signed commits, which carry both the
1394+
# armored signature and the signed payload. If either is missing
1395+
# (absent/null/empty) the commit is treated as unsigned and
1396+
# neither key is attached.
1397+
signature = commit_raw.get("signature")
1398+
payload = commit_raw.get("payload")
1399+
if signature and payload:
1400+
commit["signature"] = signature
1401+
commit["payload"] = payload
13931402
return {"commit": commit}
13941403

13951404
async def get_blame(

packages/code-storage-python/pierre_storage/types.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,9 @@ class GitStorageOptions(TypedDict, total=False):
4646

4747
OP_NO_FORCE_PUSH: Op = "no-force-push"
4848
OP_NO_PUSH: Op = "no-push"
49+
# Requires every commit introduced by a push to a matching ref to carry a valid
50+
# signature from a registered signing key.
51+
OP_VERIFY_SIG: Op = "verify-sig"
4952

5053
# Ops is a list of policy operations.
5154
Ops = List[Op]
@@ -305,7 +308,14 @@ class DeleteBranchResult(TypedDict):
305308

306309

307310
class CommitInfo(TypedDict):
308-
"""Information about a commit."""
311+
"""Information about a commit.
312+
313+
``signature`` and ``payload`` are populated only by ``get_commit`` for
314+
signed commits (``signature`` is the armored OpenPGP/SSH block from the
315+
commit's gpgsig header; ``payload`` is the exact bytes the signature is
316+
computed over). Both keys are absent for list-commits entries and for
317+
unsigned commits.
318+
"""
309319

310320
sha: str
311321
message: str
@@ -315,6 +325,8 @@ class CommitInfo(TypedDict):
315325
committer_email: str
316326
date: datetime
317327
raw_date: str
328+
signature: NotRequired[str]
329+
payload: NotRequired[str]
318330

319331

320332
class ListCommitsResult(TypedDict):
@@ -326,7 +338,10 @@ class ListCommitsResult(TypedDict):
326338

327339

328340
class GetCommitResult(TypedDict):
329-
"""Result from fetching metadata for a single commit."""
341+
"""Result from fetching metadata for a single commit.
342+
343+
For signed commits, ``commit`` carries ``signature`` and ``payload``.
344+
"""
330345

331346
commit: CommitInfo
332347

packages/code-storage-python/pierre_storage/version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""Version information for Pierre Storage SDK."""
22

33
PACKAGE_NAME = "code-storage-py-sdk"
4-
PACKAGE_VERSION = "1.10.0"
4+
PACKAGE_VERSION = "1.11.0"
55

66

77
def get_user_agent() -> str:

0 commit comments

Comments
 (0)