From ec19b48d5fe79b285653d7884df69a6ac4c72a3d Mon Sep 17 00:00:00 2001 From: Seongmin Lee Date: Fri, 26 Jun 2026 14:35:17 +0000 Subject: [PATCH] knotserver/xrpc: `git.keepCommit` Signed-off-by: Seongmin Lee --- consts/consts.go | 1 + appview/oauth/scopes.go | 1 + knotserver/xrpc/git_keep_commit.go | 149 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ knotserver/xrpc/git_keep_commit_test.go | 17 +++++++++++++++++ knotserver/xrpc/version.go | 5 ++++- knotserver/xrpc/xrpc.go | 10 ++++++++++ 6 file(s) changed, 182 insertion(s)(+), 1 deletion(s)(-) diff --git a/consts/consts.go b/consts/consts.go --- a/consts/consts.go +++ b/consts/consts.go @@ -11,3 +11,4 @@ type Capability string const CapKnotACL Capability = "knot-acl" +const CapKeepCommit Capability = "knot-keepcommit" diff --git a/appview/oauth/scopes.go b/appview/oauth/scopes.go --- a/appview/oauth/scopes.go +++ b/appview/oauth/scopes.go @@ -33,6 +33,7 @@ "rpc:sh.tangled.knot.removeMember?aud=*", "rpc:sh.tangled.ci.triggerPipeline?aud=*", "rpc:sh.tangled.ci.cancelPipeline?aud=*", + "rpc:sh.tangled.git.keepCommit?aud=*", "rpc:sh.tangled.repo.addCollaborator?aud=*", "rpc:sh.tangled.repo.addSecret?aud=*", "rpc:sh.tangled.repo.create?aud=*", diff --git a/knotserver/xrpc/git_keep_commit.go b/knotserver/xrpc/git_keep_commit.go new file mode 100644 --- /dev/null +++ b/knotserver/xrpc/git_keep_commit.go @@ -0,0 +1,149 @@ +package xrpc + +import ( + "context" + "encoding/hex" + "encoding/json" + "fmt" + "net/http" + "os/exec" + "strings" + + "github.com/bluesky-social/indigo/atproto/atclient" + "github.com/bluesky-social/indigo/atproto/syntax" + "tangled.org/core/api/tangled" +) + +func (x *Xrpc) KeepCommit(w http.ResponseWriter, r *http.Request) { + var input tangled.GitKeepCommit_Input + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + writeJson(w, http.StatusBadRequest, atclient.ErrorBody{Name: "BadRequest", Message: "failed to decode json body"}) + return + } + + if err := gitKeepCommit_Input_Validate(input); err != nil { + writeJson(w, http.StatusBadRequest, atclient.ErrorBody{Name: "BadRequest", Message: err.Error()}) + return + } + if syntax.ATURI(input.Record).RecordKey() == "" { + writeJson(w, http.StatusBadRequest, atclient.ErrorBody{Name: "BadRequest", Message: "record at-uri should have rkey"}) + return + } + + output, status, apierr := x.keepCommit(r.Context(), input) + if apierr != nil { + writeJson(w, status, apierr) + return + } + writeJson(w, status, output) +} + +func (x *Xrpc) keepCommit(ctx context.Context, input tangled.GitKeepCommit_Input) (*tangled.GitKeepCommit_Output, int, *atclient.ErrorBody) { + repoPath, _, _, err := x.Db.ResolveRepoDIDOnDisk(x.Config.Repo.ScanPath, input.Repo) + if err != nil { + return nil, http.StatusNotFound, &atclient.ErrorBody{Name: "RepoNotFound", Message: fmt.Sprintf("unknown repository: %q", input.Repo)} + } + + record := syntax.ATURI(input.Record) + recordIdent, err := x.Resolver.Directory().Lookup(ctx, record.Authority()) + if err != nil { + return nil, http.StatusBadRequest, &atclient.ErrorBody{Name: "BadRequest", Message: "Failed to resolve record authority"} + } + recordDID := recordIdent.DID + + var commitID string + + switch { + case input.Source.GitKeepCommit_Commit != nil: + source := input.Source.GitKeepCommit_Commit + if input.Repo == source.Repo { + // no-op. we already have that commit + } else { + // TODO: target repo should own the source commit + return nil, http.StatusBadRequest, &atclient.ErrorBody{Name: "BadRequest", Message: "source repo should match the target repo"} + // // fetch commit from source repo + // if err := x.fetchCommitFrom(ctx, syntax.DID(source.Repo), source.Oid); err != nil { + // return nil, http.StatusInternalServerError, &atclient.ErrorBody{Name: "CommitNotFound", Message: "Failed to fetch commit from source repo"} + // } + } + commitID = source.Oid + + case input.Source.GitKeepCommit_Patches != nil: + // TODO: apply patches to target commit and bring tip commit ID + return nil, http.StatusBadRequest, &atclient.ErrorBody{Name: "BadRequest", Message: "patches source is not supported"} + + default: + return nil, http.StatusBadRequest, &atclient.ErrorBody{Name: "BadRequest", Message: "source should be one of: [commit, patches]"} + } + + // create refs/tngl/keep/{did}/{collection}/{rkey}/{oid} + refName := EscapeGitRef(fmt.Sprintf("refs/tngl/keep/%s/%s/%s/%s", recordDID.String(), record.Collection(), record.RecordKey(), commitID)) + cmd := exec.CommandContext(ctx, "git", "-C", repoPath, "update-ref", refName, commitID) + if out, err := cmd.CombinedOutput(); err != nil { + x.Logger.Error("failed to keep commit", "err", err, "out", string(out)) + return nil, http.StatusInternalServerError, &atclient.ErrorBody{Name: "InternalServerError", Message: "Failed to keep commit"} + } + + return &tangled.GitKeepCommit_Output{ + Commit: commitID, + }, http.StatusOK, nil +} + +// lexgen doesn't give Validate() method... +func gitKeepCommit_Input_Validate(input tangled.GitKeepCommit_Input) error { + if _, err := syntax.ParseDID(input.Repo); err != nil { + return fmt.Errorf("repo: invalid repo DID: %w", err) + } + if _, err := syntax.ParseATURI(input.Record); err != nil { + return fmt.Errorf("repo: invalid record at-uri: %w", err) + } + switch { + case input.Source.GitKeepCommit_Commit != nil: + if _, err := syntax.ParseDID(input.Source.GitKeepCommit_Commit.Repo); err != nil { + return fmt.Errorf("source: commit: invalid repo DID: %w", err) + } + if ok := IsHash(input.Source.GitKeepCommit_Commit.Oid); !ok { + return fmt.Errorf("source: commit: invalid commit OID: %q", input.Source.GitKeepCommit_Commit.Oid) + } + case input.Source.GitKeepCommit_Patches != nil: + return fmt.Errorf("source: patches: patch is not supported yet") + // for i, patch := range input.Source.GitKeepCommit_Patches.Patches { + // if err := validatePatch(patch); err != nil { + // return fmt.Errorf("source: patches: invalid patches at [%d]: %w", i, err) + // } + // } + default: + return fmt.Errorf("source should be one of: [commit, patches]") + } + return nil +} + +func EscapeGitRef(s string) string { + var b strings.Builder + b.Grow(len(s) * 4 / 3) + for i := 0; i < len(s); i++ { + c := s[i] + if (c >= 'a' && c <= 'z') || + (c >= 'A' && c <= 'Z') || + (c >= '0' && c <= '9') || + c == '/' || c == '-' || c == '_' || c == '.' { + b.WriteByte(c) + continue + } + b.WriteByte('%') + b.WriteByte("0123456789ABCDEF"[c>>4]) + b.WriteByte("0123456789ABCDEF"[c&15]) + } + return strings.ToLower(b.String()) +} + +func IsHash(s string) bool { + switch len(s) { + case 40: // SHA1 + case 64: // SHA2 + default: + return false + } + _, err := hex.DecodeString(s) + return err == nil +} diff --git a/knotserver/xrpc/git_keep_commit_test.go b/knotserver/xrpc/git_keep_commit_test.go new file mode 100644 --- /dev/null +++ b/knotserver/xrpc/git_keep_commit_test.go @@ -0,0 +1,17 @@ +package xrpc + +import ( + "fmt" + "testing" + + "github.com/alecthomas/assert/v2" + "github.com/bluesky-social/indigo/atproto/syntax" +) + +func TestEscapeGitRef(t *testing.T) { + recordDID := syntax.DID("did:plc:alice") + record := syntax.ATURI("at://did:plc:alice/sh.tangled.repo.pull/something") + commitID := "what" + refName := EscapeGitRef(fmt.Sprintf("refs/tngl/keep/%s/%s/%s/%s", recordDID.String(), record.Collection(), record.RecordKey(), commitID)) + assert.Equal(t, "refs/tngl/keep/did%3aplc%3aalice/sh.tangled.repo.pull/something/what", refName) +} diff --git a/knotserver/xrpc/version.go b/knotserver/xrpc/version.go --- a/knotserver/xrpc/version.go +++ b/knotserver/xrpc/version.go @@ -12,7 +12,10 @@ // version is set during build time. var version string -var knotCapabilities = []string{string(consts.CapKnotACL)} +var knotCapabilities = []string{ + string(consts.CapKnotACL), + string(consts.CapKeepCommit), +} func (x *Xrpc) Version(w http.ResponseWriter, r *http.Request) { if version == "" { diff --git a/knotserver/xrpc/xrpc.go b/knotserver/xrpc/xrpc.go --- a/knotserver/xrpc/xrpc.go +++ b/knotserver/xrpc/xrpc.go @@ -49,6 +49,7 @@ r.Group(func(r chi.Router) { r.Use(x.ServiceAuth.VerifyServiceAuth) + r.Post("/"+tangled.GitKeepCommitNSID, x.KeepCommit) r.Post("/"+tangled.RepoSetDefaultBranchNSID, x.SetDefaultBranch) r.Post("/"+tangled.RepoDeleteBranchNSID, x.DeleteBranch) r.Post("/"+tangled.RepoCreateNSID, x.CreateRepo) @@ -194,4 +195,13 @@ } w.Header().Set("Content-Type", "application/json") w.Write(lw.buf.Bytes()) +} + +func writeJson(w http.ResponseWriter, status int, response any) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if err := json.NewEncoder(w).Encode(response); err != nil { + return err + } + return nil } -- tangled.sh