Monorepo for Tangled
0

Configure Feed

Select the types of activity you want to include in your feed.

knotserver/xrpc: `git.keepCommit`

Signed-off-by: Seongmin Lee <git@boltless.me>

Seongmin Lee (Jul 14, 2026, 1:21 AM +0900) 24d9f534 462bae3e

+182 -1
+1
appview/oauth/scopes.go
··· 33 33 "rpc:sh.tangled.knot.removeMember?aud=*", 34 34 "rpc:sh.tangled.ci.triggerPipeline?aud=*", 35 35 "rpc:sh.tangled.ci.cancelPipeline?aud=*", 36 + "rpc:sh.tangled.git.keepCommit?aud=*", 36 37 "rpc:sh.tangled.repo.addCollaborator?aud=*", 37 38 "rpc:sh.tangled.repo.addSecret?aud=*", 38 39 "rpc:sh.tangled.repo.create?aud=*",
+1
consts/consts.go
··· 11 11 type Capability string 12 12 13 13 const CapKnotACL Capability = "knot-acl" 14 + const CapKeepCommit Capability = "knot-keepcommit"
+149
knotserver/xrpc/git_keep_commit.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "context" 5 + "encoding/hex" 6 + "encoding/json" 7 + "fmt" 8 + "net/http" 9 + "os/exec" 10 + "strings" 11 + 12 + "github.com/bluesky-social/indigo/atproto/atclient" 13 + "github.com/bluesky-social/indigo/atproto/syntax" 14 + "tangled.org/core/api/tangled" 15 + ) 16 + 17 + func (x *Xrpc) KeepCommit(w http.ResponseWriter, r *http.Request) { 18 + var input tangled.GitKeepCommit_Input 19 + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { 20 + writeJson(w, http.StatusBadRequest, atclient.ErrorBody{Name: "BadRequest", Message: "failed to decode json body"}) 21 + return 22 + } 23 + 24 + if err := gitKeepCommit_Input_Validate(input); err != nil { 25 + writeJson(w, http.StatusBadRequest, atclient.ErrorBody{Name: "BadRequest", Message: err.Error()}) 26 + return 27 + } 28 + if syntax.ATURI(input.Record).RecordKey() == "" { 29 + writeJson(w, http.StatusBadRequest, atclient.ErrorBody{Name: "BadRequest", Message: "record at-uri should have rkey"}) 30 + return 31 + } 32 + 33 + output, status, apierr := x.keepCommit(r.Context(), input) 34 + if apierr != nil { 35 + writeJson(w, status, apierr) 36 + return 37 + } 38 + writeJson(w, status, output) 39 + } 40 + 41 + func (x *Xrpc) keepCommit(ctx context.Context, input tangled.GitKeepCommit_Input) (*tangled.GitKeepCommit_Output, int, *atclient.ErrorBody) { 42 + repoPath, _, _, err := x.Db.ResolveRepoDIDOnDisk(x.Config.Repo.ScanPath, input.Repo) 43 + if err != nil { 44 + return nil, http.StatusNotFound, &atclient.ErrorBody{Name: "RepoNotFound", Message: fmt.Sprintf("unknown repository: %q", input.Repo)} 45 + } 46 + 47 + record := syntax.ATURI(input.Record) 48 + recordIdent, err := x.Resolver.Directory().Lookup(ctx, record.Authority()) 49 + if err != nil { 50 + return nil, http.StatusBadRequest, &atclient.ErrorBody{Name: "BadRequest", Message: "Failed to resolve record authority"} 51 + } 52 + recordDID := recordIdent.DID 53 + 54 + var commitID string 55 + 56 + switch { 57 + case input.Source.GitKeepCommit_Commit != nil: 58 + source := input.Source.GitKeepCommit_Commit 59 + if input.Repo == source.Repo { 60 + // no-op. we already have that commit 61 + } else { 62 + // TODO: target repo should own the source commit 63 + return nil, http.StatusBadRequest, &atclient.ErrorBody{Name: "BadRequest", Message: "source repo should match the target repo"} 64 + // // fetch commit from source repo 65 + // if err := x.fetchCommitFrom(ctx, syntax.DID(source.Repo), source.Oid); err != nil { 66 + // return nil, http.StatusInternalServerError, &atclient.ErrorBody{Name: "CommitNotFound", Message: "Failed to fetch commit from source repo"} 67 + // } 68 + } 69 + commitID = source.Oid 70 + 71 + case input.Source.GitKeepCommit_Patches != nil: 72 + // TODO: apply patches to target commit and bring tip commit ID 73 + return nil, http.StatusBadRequest, &atclient.ErrorBody{Name: "BadRequest", Message: "patches source is not supported"} 74 + 75 + default: 76 + return nil, http.StatusBadRequest, &atclient.ErrorBody{Name: "BadRequest", Message: "source should be one of: [commit, patches]"} 77 + } 78 + 79 + // create refs/tngl/keep/{did}/{collection}/{rkey}/{oid} 80 + refName := EscapeGitRef(fmt.Sprintf("refs/tngl/keep/%s/%s/%s/%s", recordDID.String(), record.Collection(), record.RecordKey(), commitID)) 81 + cmd := exec.CommandContext(ctx, "git", "-C", repoPath, "update-ref", refName, commitID) 82 + if out, err := cmd.CombinedOutput(); err != nil { 83 + x.Logger.Error("failed to keep commit", "err", err, "out", string(out)) 84 + return nil, http.StatusInternalServerError, &atclient.ErrorBody{Name: "InternalServerError", Message: "Failed to keep commit"} 85 + } 86 + 87 + return &tangled.GitKeepCommit_Output{ 88 + Commit: commitID, 89 + }, http.StatusOK, nil 90 + } 91 + 92 + // lexgen doesn't give Validate() method... 93 + func gitKeepCommit_Input_Validate(input tangled.GitKeepCommit_Input) error { 94 + if _, err := syntax.ParseDID(input.Repo); err != nil { 95 + return fmt.Errorf("repo: invalid repo DID: %w", err) 96 + } 97 + if _, err := syntax.ParseATURI(input.Record); err != nil { 98 + return fmt.Errorf("repo: invalid record at-uri: %w", err) 99 + } 100 + switch { 101 + case input.Source.GitKeepCommit_Commit != nil: 102 + if _, err := syntax.ParseDID(input.Source.GitKeepCommit_Commit.Repo); err != nil { 103 + return fmt.Errorf("source: commit: invalid repo DID: %w", err) 104 + } 105 + if ok := IsHash(input.Source.GitKeepCommit_Commit.Oid); !ok { 106 + return fmt.Errorf("source: commit: invalid commit OID: %q", input.Source.GitKeepCommit_Commit.Oid) 107 + } 108 + case input.Source.GitKeepCommit_Patches != nil: 109 + return fmt.Errorf("source: patches: patch is not supported yet") 110 + // for i, patch := range input.Source.GitKeepCommit_Patches.Patches { 111 + // if err := validatePatch(patch); err != nil { 112 + // return fmt.Errorf("source: patches: invalid patches at [%d]: %w", i, err) 113 + // } 114 + // } 115 + default: 116 + return fmt.Errorf("source should be one of: [commit, patches]") 117 + } 118 + return nil 119 + } 120 + 121 + func EscapeGitRef(s string) string { 122 + var b strings.Builder 123 + b.Grow(len(s) * 4 / 3) 124 + for i := 0; i < len(s); i++ { 125 + c := s[i] 126 + if (c >= 'a' && c <= 'z') || 127 + (c >= 'A' && c <= 'Z') || 128 + (c >= '0' && c <= '9') || 129 + c == '/' || c == '-' || c == '_' || c == '.' { 130 + b.WriteByte(c) 131 + continue 132 + } 133 + b.WriteByte('%') 134 + b.WriteByte("0123456789ABCDEF"[c>>4]) 135 + b.WriteByte("0123456789ABCDEF"[c&15]) 136 + } 137 + return strings.ToLower(b.String()) 138 + } 139 + 140 + func IsHash(s string) bool { 141 + switch len(s) { 142 + case 40: // SHA1 143 + case 64: // SHA2 144 + default: 145 + return false 146 + } 147 + _, err := hex.DecodeString(s) 148 + return err == nil 149 + }
+17
knotserver/xrpc/git_keep_commit_test.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "fmt" 5 + "testing" 6 + 7 + "github.com/alecthomas/assert/v2" 8 + "github.com/bluesky-social/indigo/atproto/syntax" 9 + ) 10 + 11 + func TestEscapeGitRef(t *testing.T) { 12 + recordDID := syntax.DID("did:plc:alice") 13 + record := syntax.ATURI("at://did:plc:alice/sh.tangled.repo.pull/something") 14 + commitID := "what" 15 + refName := EscapeGitRef(fmt.Sprintf("refs/tngl/keep/%s/%s/%s/%s", recordDID.String(), record.Collection(), record.RecordKey(), commitID)) 16 + assert.Equal(t, "refs/tngl/keep/did%3aplc%3aalice/sh.tangled.repo.pull/something/what", refName) 17 + }
+4 -1
knotserver/xrpc/version.go
··· 12 12 // version is set during build time. 13 13 var version string 14 14 15 - var knotCapabilities = []string{string(consts.CapKnotACL)} 15 + var knotCapabilities = []string{ 16 + string(consts.CapKnotACL), 17 + string(consts.CapKeepCommit), 18 + } 16 19 17 20 func (x *Xrpc) Version(w http.ResponseWriter, r *http.Request) { 18 21 if version == "" {
+10
knotserver/xrpc/xrpc.go
··· 48 48 r.Group(func(r chi.Router) { 49 49 r.Use(x.ServiceAuth.VerifyServiceAuth) 50 50 51 + r.Post("/"+tangled.GitKeepCommitNSID, x.KeepCommit) 51 52 r.Post("/"+tangled.RepoSetDefaultBranchNSID, x.SetDefaultBranch) 52 53 r.Post("/"+tangled.RepoDeleteBranchNSID, x.DeleteBranch) 53 54 r.Post("/"+tangled.RepoCreateNSID, x.CreateRepo) ··· 169 170 w.Header().Set("Content-Type", "application/json") 170 171 w.Write(lw.buf.Bytes()) 171 172 } 173 + 174 + func writeJson(w http.ResponseWriter, status int, response any) error { 175 + w.Header().Set("Content-Type", "application/json") 176 + w.WriteHeader(status) 177 + if err := json.NewEncoder(w).Encode(response); err != nil { 178 + return err 179 + } 180 + return nil 181 + }