···7070 id integer primary key autoincrement,
7171 name text unique
7272 );
7373+7474+ create table if not exists branch_rules (
7575+ repo text not null,
7676+ name text not null,
7777+ active bool not null,
7878+ branch_patterns text not null, -- json array
7979+ blocked_actions text not null, -- json array
8080+ excluded_dids text, -- json array
8181+ unique(repo, name)
8282+ );
7383 `)
7484 if err != nil {
7585 return nil, err
+134
knotserver/xrpc/create_repo_branch_rule.go
···11+package xrpc
22+33+import (
44+ "encoding/json"
55+ "errors"
66+ "fmt"
77+ "net/http"
88+ "regexp"
99+1010+ "github.com/mattn/go-sqlite3"
1111+1212+ comatproto "github.com/bluesky-social/indigo/api/atproto"
1313+ "github.com/bluesky-social/indigo/atproto/syntax"
1414+ "github.com/bluesky-social/indigo/xrpc"
1515+ securejoin "github.com/cyphar/filepath-securejoin"
1616+ "tangled.org/core/api/tangled"
1717+ "tangled.org/core/knotserver/db"
1818+ "tangled.org/core/rbac"
1919+ xrpcerr "tangled.org/core/xrpc/errors"
2020+)
2121+2222+func (x *Xrpc) CreateRepoBranchRule(w http.ResponseWriter, r *http.Request) {
2323+ l := x.Logger.With("handler", "CreateRepoBranchRule")
2424+ fail := func(e xrpcerr.XrpcError) {
2525+ l.Error("failed", "kind", e.Tag, "error", e.Message)
2626+ writeError(w, e, http.StatusBadRequest)
2727+ }
2828+2929+ actorDid, ok := r.Context().Value(ActorDid).(syntax.DID)
3030+ if !ok {
3131+ fail(xrpcerr.MissingActorDidError)
3232+ return
3333+ }
3434+3535+ var data tangled.RepoCreateBranchRule_Input
3636+ if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
3737+ fail(xrpcerr.GenericError(err))
3838+ return
3939+ }
4040+4141+ if err := validateBranchRuleInput(data); err != nil {
4242+ fail(xrpcerr.NewXrpcError(
4343+ xrpcerr.WithTag("InvalidRequest"),
4444+ xrpcerr.WithMessage(err.Error()),
4545+ ))
4646+ return
4747+ }
4848+4949+ repoAt, err := syntax.ParseATURI(data.Repo)
5050+ if err != nil {
5151+ fail(xrpcerr.InvalidRepoError(data.Repo))
5252+ return
5353+ }
5454+5555+ ident, err := x.Resolver.ResolveIdent(r.Context(), repoAt.Authority().String())
5656+ if err != nil || ident.Handle.IsInvalidHandle() {
5757+ fail(xrpcerr.GenericError(fmt.Errorf("failed to resolve handle: %w", err)))
5858+ return
5959+ }
6060+6161+ xrpcc := xrpc.Client{Host: ident.PDSEndpoint()}
6262+ resp, err := comatproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String())
6363+ if err != nil {
6464+ fail(xrpcerr.GenericError(err))
6565+ return
6666+ }
6767+6868+ repo := resp.Value.Val.(*tangled.Repo)
6969+ didPath, err := securejoin.SecureJoin(ident.DID.String(), repo.Name)
7070+ if err != nil {
7171+ fail(xrpcerr.GenericError(err))
7272+ return
7373+ }
7474+7575+ isOwner, err := x.Enforcer.IsRepoOwner(actorDid.String(), rbac.ThisServer, didPath)
7676+ if err != nil {
7777+ fail(xrpcerr.GenericError(err))
7878+ return
7979+ }
8080+ if !isOwner {
8181+ l.Error("insufficient permissions", "did", actorDid.String(), "repo", didPath)
8282+ writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized)
8383+ return
8484+ }
8585+8686+ rule := db.BranchRule{
8787+ Repo: didPath,
8888+ RepoBranchRule: data.RepoBranchRule,
8989+ }
9090+9191+ if err := x.Db.AddBranchRule(rule); err != nil {
9292+ var sqliteErr sqlite3.Error
9393+ if errors.As(err, &sqliteErr) && sqliteErr.ExtendedCode == sqlite3.ErrConstraintUnique {
9494+ fail(xrpcerr.NewXrpcError(
9595+ xrpcerr.WithTag("RuleExists"),
9696+ xrpcerr.WithMessage("a branch rule with this name already exists for this repository"),
9797+ ))
9898+ return
9999+ }
100100+ l.Error("adding branch rule", "error", err.Error())
101101+ writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError)
102102+ return
103103+ }
104104+105105+ w.WriteHeader(http.StatusOK)
106106+}
107107+108108+func validateBranchRuleInput(data tangled.RepoCreateBranchRule_Input) error {
109109+ if data.Name == "" {
110110+ return fmt.Errorf("rule name is required")
111111+ }
112112+ if len(data.BranchRegexPatterns) == 0 {
113113+ return fmt.Errorf("at least one branch pattern is required")
114114+ }
115115+ if len(data.BlockedActions) == 0 {
116116+ return fmt.Errorf("at least one blocked action is required")
117117+ }
118118+ for _, p := range data.BranchRegexPatterns {
119119+ if _, err := regexp.Compile(p); err != nil {
120120+ return fmt.Errorf("invalid branch pattern %q: %w", p, err)
121121+ }
122122+ }
123123+ for _, a := range data.BlockedActions {
124124+ if !a.IsValid() {
125125+ return fmt.Errorf("invalid blocked action %q: must be one of direct-push, force-push, delete", a)
126126+ }
127127+ }
128128+ for _, d := range data.ExcludedDids {
129129+ if _, err := syntax.ParseDID(d); err != nil {
130130+ return fmt.Errorf("invalid DID %q: %w", d, err)
131131+ }
132132+ }
133133+ return nil
134134+}