Monorepo for Tangled
0
fork

Configure Feed

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

knotserver/xrpc,db: add table and crud endpoints for branch rules

Signed-off-by: Peter Lovett <pelovett@gmail.com>

+484
+89
knotserver/db/branch_rules.go
··· 1 + package db 2 + 3 + import ( 4 + "encoding/json" 5 + 6 + "tangled.org/core/api/tangled" 7 + ) 8 + 9 + type BranchRule struct { 10 + Repo string 11 + tangled.RepoBranchRule 12 + } 13 + 14 + func (d *DB) GetBranchRules(repo string) ([]BranchRule, error) { 15 + rows, err := d.db.Query( 16 + `select repo, name, active, branch_patterns, blocked_actions, excluded_dids from branch_rules where repo = ?`, 17 + repo, 18 + ) 19 + if err != nil { 20 + return nil, err 21 + } 22 + defer rows.Close() 23 + 24 + var rules []BranchRule 25 + for rows.Next() { 26 + var rule BranchRule 27 + var branchPatterns, blockedActions, excludedDids []byte 28 + if err := rows.Scan(&rule.Repo, &rule.Name, &rule.Active, &branchPatterns, &blockedActions, &excludedDids); err != nil { 29 + return nil, err 30 + } 31 + if err := json.Unmarshal(branchPatterns, &rule.BranchRegexPatterns); err != nil { 32 + return nil, err 33 + } 34 + if err := json.Unmarshal(blockedActions, &rule.BlockedActions); err != nil { 35 + return nil, err 36 + } 37 + if excludedDids != nil { 38 + if err := json.Unmarshal(excludedDids, &rule.ExcludedDids); err != nil { 39 + return nil, err 40 + } 41 + } 42 + rules = append(rules, rule) 43 + } 44 + 45 + return rules, rows.Err() 46 + } 47 + 48 + func (d *DB) DeleteBranchRule(repo, name string) error { 49 + _, err := d.db.Exec(`delete from branch_rules where repo = ? and name = ?`, repo, name) 50 + return err 51 + } 52 + 53 + func (d *DB) UpdateBranchRule(oldName string, rule BranchRule) error { 54 + branchPatterns, err := json.Marshal(rule.BranchRegexPatterns) 55 + if err != nil { 56 + return err 57 + } 58 + blockedActions, err := json.Marshal(rule.BlockedActions) 59 + if err != nil { 60 + return err 61 + } 62 + excludedDids, err := json.Marshal(rule.ExcludedDids) 63 + if err != nil { 64 + return err 65 + } 66 + 67 + query := `update branch_rules set name = ?, active = ?, branch_patterns = ?, blocked_actions = ?, excluded_dids = ? where repo = ? and name = ?` 68 + _, err = d.db.Exec(query, rule.Name, rule.Active, branchPatterns, blockedActions, excludedDids, rule.Repo, oldName) 69 + return err 70 + } 71 + 72 + func (d *DB) AddBranchRule(rule BranchRule) error { 73 + branchPatterns, err := json.Marshal(rule.BranchRegexPatterns) 74 + if err != nil { 75 + return err 76 + } 77 + blockedActions, err := json.Marshal(rule.BlockedActions) 78 + if err != nil { 79 + return err 80 + } 81 + excludedDids, err := json.Marshal(rule.ExcludedDids) 82 + if err != nil { 83 + return err 84 + } 85 + 86 + query := `insert into branch_rules (repo, name, active, branch_patterns, blocked_actions, excluded_dids) values (?, ?, ?, ?, ?, ?)` 87 + _, err = d.db.Exec(query, rule.Repo, rule.Name, rule.Active, branchPatterns, blockedActions, excludedDids) 88 + return err 89 + }
+10
knotserver/db/db.go
··· 70 70 id integer primary key autoincrement, 71 71 name text unique 72 72 ); 73 + 74 + create table if not exists branch_rules ( 75 + repo text not null, 76 + name text not null, 77 + active bool not null, 78 + branch_patterns text not null, -- json array 79 + blocked_actions text not null, -- json array 80 + excluded_dids text, -- json array 81 + unique(repo, name) 82 + ); 73 83 `) 74 84 if err != nil { 75 85 return nil, err
+134
knotserver/xrpc/create_repo_branch_rule.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "errors" 6 + "fmt" 7 + "net/http" 8 + "regexp" 9 + 10 + "github.com/mattn/go-sqlite3" 11 + 12 + comatproto "github.com/bluesky-social/indigo/api/atproto" 13 + "github.com/bluesky-social/indigo/atproto/syntax" 14 + "github.com/bluesky-social/indigo/xrpc" 15 + securejoin "github.com/cyphar/filepath-securejoin" 16 + "tangled.org/core/api/tangled" 17 + "tangled.org/core/knotserver/db" 18 + "tangled.org/core/rbac" 19 + xrpcerr "tangled.org/core/xrpc/errors" 20 + ) 21 + 22 + func (x *Xrpc) CreateRepoBranchRule(w http.ResponseWriter, r *http.Request) { 23 + l := x.Logger.With("handler", "CreateRepoBranchRule") 24 + fail := func(e xrpcerr.XrpcError) { 25 + l.Error("failed", "kind", e.Tag, "error", e.Message) 26 + writeError(w, e, http.StatusBadRequest) 27 + } 28 + 29 + actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) 30 + if !ok { 31 + fail(xrpcerr.MissingActorDidError) 32 + return 33 + } 34 + 35 + var data tangled.RepoCreateBranchRule_Input 36 + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 37 + fail(xrpcerr.GenericError(err)) 38 + return 39 + } 40 + 41 + if err := validateBranchRuleInput(data); err != nil { 42 + fail(xrpcerr.NewXrpcError( 43 + xrpcerr.WithTag("InvalidRequest"), 44 + xrpcerr.WithMessage(err.Error()), 45 + )) 46 + return 47 + } 48 + 49 + repoAt, err := syntax.ParseATURI(data.Repo) 50 + if err != nil { 51 + fail(xrpcerr.InvalidRepoError(data.Repo)) 52 + return 53 + } 54 + 55 + ident, err := x.Resolver.ResolveIdent(r.Context(), repoAt.Authority().String()) 56 + if err != nil || ident.Handle.IsInvalidHandle() { 57 + fail(xrpcerr.GenericError(fmt.Errorf("failed to resolve handle: %w", err))) 58 + return 59 + } 60 + 61 + xrpcc := xrpc.Client{Host: ident.PDSEndpoint()} 62 + resp, err := comatproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String()) 63 + if err != nil { 64 + fail(xrpcerr.GenericError(err)) 65 + return 66 + } 67 + 68 + repo := resp.Value.Val.(*tangled.Repo) 69 + didPath, err := securejoin.SecureJoin(ident.DID.String(), repo.Name) 70 + if err != nil { 71 + fail(xrpcerr.GenericError(err)) 72 + return 73 + } 74 + 75 + isOwner, err := x.Enforcer.IsRepoOwner(actorDid.String(), rbac.ThisServer, didPath) 76 + if err != nil { 77 + fail(xrpcerr.GenericError(err)) 78 + return 79 + } 80 + if !isOwner { 81 + l.Error("insufficient permissions", "did", actorDid.String(), "repo", didPath) 82 + writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized) 83 + return 84 + } 85 + 86 + rule := db.BranchRule{ 87 + Repo: didPath, 88 + RepoBranchRule: data.RepoBranchRule, 89 + } 90 + 91 + if err := x.Db.AddBranchRule(rule); err != nil { 92 + var sqliteErr sqlite3.Error 93 + if errors.As(err, &sqliteErr) && sqliteErr.ExtendedCode == sqlite3.ErrConstraintUnique { 94 + fail(xrpcerr.NewXrpcError( 95 + xrpcerr.WithTag("RuleExists"), 96 + xrpcerr.WithMessage("a branch rule with this name already exists for this repository"), 97 + )) 98 + return 99 + } 100 + l.Error("adding branch rule", "error", err.Error()) 101 + writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) 102 + return 103 + } 104 + 105 + w.WriteHeader(http.StatusOK) 106 + } 107 + 108 + func validateBranchRuleInput(data tangled.RepoCreateBranchRule_Input) error { 109 + if data.Name == "" { 110 + return fmt.Errorf("rule name is required") 111 + } 112 + if len(data.BranchRegexPatterns) == 0 { 113 + return fmt.Errorf("at least one branch pattern is required") 114 + } 115 + if len(data.BlockedActions) == 0 { 116 + return fmt.Errorf("at least one blocked action is required") 117 + } 118 + for _, p := range data.BranchRegexPatterns { 119 + if _, err := regexp.Compile(p); err != nil { 120 + return fmt.Errorf("invalid branch pattern %q: %w", p, err) 121 + } 122 + } 123 + for _, a := range data.BlockedActions { 124 + if !a.IsValid() { 125 + return fmt.Errorf("invalid blocked action %q: must be one of direct-push, force-push, delete", a) 126 + } 127 + } 128 + for _, d := range data.ExcludedDids { 129 + if _, err := syntax.ParseDID(d); err != nil { 130 + return fmt.Errorf("invalid DID %q: %w", d, err) 131 + } 132 + } 133 + return nil 134 + }
+88
knotserver/xrpc/delete_repo_branch_rule.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "net/http" 7 + 8 + comatproto "github.com/bluesky-social/indigo/api/atproto" 9 + "github.com/bluesky-social/indigo/atproto/syntax" 10 + "github.com/bluesky-social/indigo/xrpc" 11 + securejoin "github.com/cyphar/filepath-securejoin" 12 + "tangled.org/core/api/tangled" 13 + "tangled.org/core/rbac" 14 + xrpcerr "tangled.org/core/xrpc/errors" 15 + ) 16 + 17 + func (x *Xrpc) DeleteRepoBranchRule(w http.ResponseWriter, r *http.Request) { 18 + l := x.Logger.With("handler", "DeleteRepoBranchRule") 19 + fail := func(e xrpcerr.XrpcError) { 20 + l.Error("failed", "kind", e.Tag, "error", e.Message) 21 + writeError(w, e, http.StatusBadRequest) 22 + } 23 + 24 + actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) 25 + if !ok { 26 + fail(xrpcerr.MissingActorDidError) 27 + return 28 + } 29 + 30 + var data tangled.RepoDeleteBranchRule_Input 31 + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 32 + fail(xrpcerr.GenericError(err)) 33 + return 34 + } 35 + 36 + if data.Name == "" { 37 + fail(xrpcerr.NewXrpcError( 38 + xrpcerr.WithTag("InvalidRequest"), 39 + xrpcerr.WithMessage("rule name is required"), 40 + )) 41 + return 42 + } 43 + 44 + repoAt, err := syntax.ParseATURI(data.Repo) 45 + if err != nil { 46 + fail(xrpcerr.InvalidRepoError(data.Repo)) 47 + return 48 + } 49 + 50 + ident, err := x.Resolver.ResolveIdent(r.Context(), repoAt.Authority().String()) 51 + if err != nil || ident.Handle.IsInvalidHandle() { 52 + fail(xrpcerr.GenericError(fmt.Errorf("failed to resolve handle: %w", err))) 53 + return 54 + } 55 + 56 + xrpcc := xrpc.Client{Host: ident.PDSEndpoint()} 57 + resp, err := comatproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String()) 58 + if err != nil { 59 + fail(xrpcerr.GenericError(err)) 60 + return 61 + } 62 + 63 + repo := resp.Value.Val.(*tangled.Repo) 64 + didPath, err := securejoin.SecureJoin(ident.DID.String(), repo.Name) 65 + if err != nil { 66 + fail(xrpcerr.GenericError(err)) 67 + return 68 + } 69 + 70 + isOwner, err := x.Enforcer.IsRepoOwner(actorDid.String(), rbac.ThisServer, didPath) 71 + if err != nil { 72 + fail(xrpcerr.GenericError(err)) 73 + return 74 + } 75 + if !isOwner { 76 + l.Error("insufficient permissions", "did", actorDid.String(), "repo", didPath) 77 + writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized) 78 + return 79 + } 80 + 81 + if err := x.Db.DeleteBranchRule(didPath, data.Name); err != nil { 82 + l.Error("deleting branch rule", "error", err.Error()) 83 + writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) 84 + return 85 + } 86 + 87 + w.WriteHeader(http.StatusOK) 88 + }
+43
knotserver/xrpc/list_branch_rules.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "net/http" 5 + 6 + "tangled.org/core/api/tangled" 7 + xrpcerr "tangled.org/core/xrpc/errors" 8 + ) 9 + 10 + func (x *Xrpc) ListBranchRules(w http.ResponseWriter, r *http.Request) { 11 + l := x.Logger.With("handler", "ListBranchRules") 12 + 13 + repo := r.URL.Query().Get("repo") 14 + if repo == "" { 15 + writeError(w, xrpcerr.NewXrpcError( 16 + xrpcerr.WithTag("InvalidRequest"), 17 + xrpcerr.WithMessage("missing repo parameter"), 18 + ), http.StatusBadRequest) 19 + return 20 + } 21 + 22 + // repo param is did/repoName — validate it parses correctly 23 + if _, err := x.parseRepoParam(repo); err != nil { 24 + writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest) 25 + return 26 + } 27 + 28 + rules, err := x.Db.GetBranchRules(repo) 29 + if err != nil { 30 + l.Error("listing branch rules", "error", err.Error(), "repo", repo) 31 + writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) 32 + return 33 + } 34 + 35 + out := tangled.RepoListBranchRules_Output{ 36 + Rules: make([]tangled.RepoBranchRule, 0, len(rules)), 37 + } 38 + for _, rule := range rules { 39 + out.Rules = append(out.Rules, rule.RepoBranchRule) 40 + } 41 + 42 + writeJson(w, out) 43 + }
+116
knotserver/xrpc/update_repo_branch_rule.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "errors" 6 + "fmt" 7 + "net/http" 8 + 9 + "github.com/mattn/go-sqlite3" 10 + 11 + comatproto "github.com/bluesky-social/indigo/api/atproto" 12 + "github.com/bluesky-social/indigo/atproto/syntax" 13 + "github.com/bluesky-social/indigo/xrpc" 14 + securejoin "github.com/cyphar/filepath-securejoin" 15 + "tangled.org/core/api/tangled" 16 + "tangled.org/core/knotserver/db" 17 + "tangled.org/core/rbac" 18 + xrpcerr "tangled.org/core/xrpc/errors" 19 + ) 20 + 21 + func (x *Xrpc) UpdateRepoBranchRule(w http.ResponseWriter, r *http.Request) { 22 + l := x.Logger.With("handler", "UpdateRepoBranchRule") 23 + fail := func(e xrpcerr.XrpcError) { 24 + l.Error("failed", "kind", e.Tag, "error", e.Message) 25 + writeError(w, e, http.StatusBadRequest) 26 + } 27 + 28 + actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) 29 + if !ok { 30 + fail(xrpcerr.MissingActorDidError) 31 + return 32 + } 33 + 34 + var data tangled.RepoUpdateBranchRule_Input 35 + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 36 + fail(xrpcerr.GenericError(err)) 37 + return 38 + } 39 + 40 + if data.OriginalName == "" { 41 + fail(xrpcerr.NewXrpcError( 42 + xrpcerr.WithTag("InvalidRequest"), 43 + xrpcerr.WithMessage("original rule name is required"), 44 + )) 45 + return 46 + } 47 + 48 + if err := validateBranchRuleInput(tangled.RepoCreateBranchRule_Input{ 49 + Repo: data.Repo, 50 + RepoBranchRule: data.RepoBranchRule, 51 + }); err != nil { 52 + fail(xrpcerr.NewXrpcError( 53 + xrpcerr.WithTag("InvalidRequest"), 54 + xrpcerr.WithMessage(err.Error()), 55 + )) 56 + return 57 + } 58 + 59 + repoAt, err := syntax.ParseATURI(data.Repo) 60 + if err != nil { 61 + fail(xrpcerr.InvalidRepoError(data.Repo)) 62 + return 63 + } 64 + 65 + ident, err := x.Resolver.ResolveIdent(r.Context(), repoAt.Authority().String()) 66 + if err != nil || ident.Handle.IsInvalidHandle() { 67 + fail(xrpcerr.GenericError(fmt.Errorf("failed to resolve handle: %w", err))) 68 + return 69 + } 70 + 71 + xrpcc := xrpc.Client{Host: ident.PDSEndpoint()} 72 + resp, err := comatproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String()) 73 + if err != nil { 74 + fail(xrpcerr.GenericError(err)) 75 + return 76 + } 77 + 78 + repo := resp.Value.Val.(*tangled.Repo) 79 + didPath, err := securejoin.SecureJoin(ident.DID.String(), repo.Name) 80 + if err != nil { 81 + fail(xrpcerr.GenericError(err)) 82 + return 83 + } 84 + 85 + isOwner, err := x.Enforcer.IsRepoOwner(actorDid.String(), rbac.ThisServer, didPath) 86 + if err != nil { 87 + fail(xrpcerr.GenericError(err)) 88 + return 89 + } 90 + if !isOwner { 91 + l.Error("insufficient permissions", "did", actorDid.String(), "repo", didPath) 92 + writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized) 93 + return 94 + } 95 + 96 + rule := db.BranchRule{ 97 + Repo: didPath, 98 + RepoBranchRule: data.RepoBranchRule, 99 + } 100 + 101 + if err := x.Db.UpdateBranchRule(data.OriginalName, rule); err != nil { 102 + var sqliteErr sqlite3.Error 103 + if errors.As(err, &sqliteErr) && sqliteErr.ExtendedCode == sqlite3.ErrConstraintUnique { 104 + fail(xrpcerr.NewXrpcError( 105 + xrpcerr.WithTag("RuleExists"), 106 + xrpcerr.WithMessage("a branch rule with this name already exists for this repository"), 107 + )) 108 + return 109 + } 110 + l.Error("updating branch rule", "error", err.Error()) 111 + writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) 112 + return 113 + } 114 + 115 + w.WriteHeader(http.StatusOK) 116 + }
+4
knotserver/xrpc/xrpc.go
··· 45 45 r.Post("/"+tangled.RepoForkSyncNSID, x.ForkSync) 46 46 r.Post("/"+tangled.RepoHiddenRefNSID, x.HiddenRef) 47 47 r.Post("/"+tangled.RepoMergeNSID, x.Merge) 48 + r.Post("/"+tangled.RepoCreateBranchRuleNSID, x.CreateRepoBranchRule) 49 + r.Post("/"+tangled.RepoUpdateBranchRuleNSID, x.UpdateRepoBranchRule) 50 + r.Post("/"+tangled.RepoDeleteBranchRuleNSID, x.DeleteRepoBranchRule) 48 51 }) 49 52 50 53 // merge check is an open endpoint ··· 55 58 r.Post("/"+tangled.RepoMergeCheckNSID, x.MergeCheck) 56 59 57 60 // repo query endpoints (no auth required) 61 + r.Get("/"+tangled.RepoListBranchRulesNSID, x.ListBranchRules) 58 62 r.Get("/"+tangled.RepoTreeNSID, x.RepoTree) 59 63 r.Get("/"+tangled.RepoLogNSID, x.RepoLog) 60 64 r.Get("/"+tangled.RepoBranchesNSID, x.RepoBranches)