Monorepo for Tangled tangled.org

knotserver|appview: add branch rules #1235

open opened by pyotr.bsky.social targeting master from pyotr.bsky.social/core: adding-branch-rules
Labels

None yet.

assignee

None yet.

Participants 1
AT URI
at://did:plc:uxmy3zztxyhfk6mxrkun5tpr/sh.tangled.repo.pull/3mi5xihwr6c22
+1439 -11
Diff #1
+28
api/tangled/repoUpdateBranchRule.go
··· 1 + package tangled 2 + 3 + import ( 4 + "context" 5 + 6 + "github.com/bluesky-social/indigo/lex/util" 7 + ) 8 + 9 + const ( 10 + RepoUpdateBranchRuleNSID = "sh.tangled.repo.updateBranchRule" 11 + ) 12 + 13 + // RepoUpdateBranchRule calls the XRPC method "sh.tangled.repo.updateBranchRule". 14 + func RepoUpdateBranchRule(ctx context.Context, c util.LexClient, input *RepoUpdateBranchRule_Input) error { 15 + if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.updateBranchRule", nil, input, nil); err != nil { 16 + return err 17 + } 18 + return nil 19 + } 20 + 21 + // RepoUpdateBranchRule_Input is the input argument to a sh.tangled.repo.updateBranchRule call. 22 + type RepoUpdateBranchRule_Input struct { 23 + // Repo is the AT-URI of the repository record. 24 + Repo string `json:"repo"` 25 + // OriginalName is the current name of the rule to update (used as the lookup key). 26 + OriginalName string `json:"originalName"` 27 + RepoBranchRule 28 + }
+26
api/tangled/repocreateBranchRule.go
··· 1 + package tangled 2 + 3 + import ( 4 + "context" 5 + 6 + "github.com/bluesky-social/indigo/lex/util" 7 + ) 8 + 9 + const ( 10 + RepoCreateBranchRuleNSID = "sh.tangled.repo.createBranchRule" 11 + ) 12 + 13 + // RepoCreateBranchRule calls the XRPC method "sh.tangled.repo.createBranchRule". 14 + func RepoCreateBranchRule(ctx context.Context, c util.LexClient, input *RepoCreateBranchRule_Input) error { 15 + if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.createBranchRule", nil, input, nil); err != nil { 16 + return err 17 + } 18 + return nil 19 + } 20 + 21 + // RepoCreateBranchRule_Input is the input argument to a sh.tangled.repo.createBranchRule call. 22 + type RepoCreateBranchRule_Input struct { 23 + // Repo is the AT-URI of the repository record. 24 + Repo string `json:"repo"` 25 + RepoBranchRule 26 + }
+27
api/tangled/repodeleteBranchRule.go
··· 1 + package tangled 2 + 3 + import ( 4 + "context" 5 + 6 + "github.com/bluesky-social/indigo/lex/util" 7 + ) 8 + 9 + const ( 10 + RepoDeleteBranchRuleNSID = "sh.tangled.repo.deleteBranchRule" 11 + ) 12 + 13 + // RepoDeleteBranchRule_Input is the input argument to a sh.tangled.repo.deleteBranchRule call. 14 + type RepoDeleteBranchRule_Input struct { 15 + // Repo is the AT-URI of the repository record. 16 + Repo string `json:"repo"` 17 + // Name is the name of the branch rule to delete. 18 + Name string `json:"name"` 19 + } 20 + 21 + // RepoDeleteBranchRule calls the XRPC method "sh.tangled.repo.deleteBranchRule". 22 + func RepoDeleteBranchRule(ctx context.Context, c util.LexClient, input *RepoDeleteBranchRule_Input) error { 23 + if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.deleteBranchRule", nil, input, nil); err != nil { 24 + return err 25 + } 26 + return nil 27 + }
+61
api/tangled/repolistBranchRules.go
··· 1 + package tangled 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + 7 + "github.com/bluesky-social/indigo/lex/util" 8 + ) 9 + 10 + const ( 11 + RepoListBranchRulesNSID = "sh.tangled.repo.listBranchRules" 12 + ) 13 + 14 + // RepoListBranchRules calls the XRPC method "sh.tangled.repo.listBranchRules". 15 + // 16 + // repo: Repository identifier in format 'did/repoName' 17 + func RepoListBranchRules(ctx context.Context, c util.LexClient, repo string) ([]byte, error) { 18 + buf := new(bytes.Buffer) 19 + params := map[string]interface{}{ 20 + "repo": repo, 21 + } 22 + if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.listBranchRules", params, nil, buf); err != nil { 23 + return nil, err 24 + } 25 + return buf.Bytes(), nil 26 + } 27 + 28 + // BlockedAction represents an action that can be blocked on a protected branch. 29 + type BlockedAction string 30 + 31 + const ( 32 + // BlockedActionDirectPush blocks direct pushes to matching branches, requiring 33 + // changes to go through a pull request instead. 34 + BlockedActionDirectPush BlockedAction = "direct-push" 35 + // BlockedActionForcePush blocks force pushes to matching branches. 36 + BlockedActionForcePush BlockedAction = "force-push" 37 + // BlockedActionDelete blocks deletion of matching branches. 38 + BlockedActionDelete BlockedAction = "delete" 39 + ) 40 + 41 + // IsValid reports whether the action is a recognised BlockedAction value. 42 + func (a BlockedAction) IsValid() bool { 43 + switch a { 44 + case BlockedActionDirectPush, BlockedActionForcePush, BlockedActionDelete: 45 + return true 46 + } 47 + return false 48 + } 49 + 50 + type RepoListBranchRules_Output struct { 51 + Rules []RepoBranchRule `json:"rules"` 52 + } 53 + 54 + type RepoBranchRule struct { 55 + Name string `json:"name"` 56 + Active bool `json:"active"` 57 + // BranchRegexPatterns is a list of Go regex patterns matched against branch names. 58 + BranchRegexPatterns []string `json:"branchRegexPatterns"` 59 + BlockedActions []BlockedAction `json:"blockedActions"` 60 + ExcludedDids []string `json:"excludedDids,omitempty"` 61 + }
+3
appview/oauth/scopes.go
··· 38 38 "rpc:sh.tangled.repo.addSecret?aud=*", 39 39 "rpc:sh.tangled.repo.removeSecret?aud=*", 40 40 "rpc:sh.tangled.repo.listSecrets?aud=*", 41 + "rpc:sh.tangled.repo.createBranchRule?aud=*", 42 + "rpc:sh.tangled.repo.updateBranchRule?aud=*", 43 + "rpc:sh.tangled.repo.deleteBranchRule?aud=*", 41 44 }
+1
appview/pages/funcmap.go
··· 496 496 {"Name": "pipelines", "Icon": "layers-2"}, 497 497 {"Name": "hooks", "Icon": "webhook"}, 498 498 {"Name": "sites", "Icon": "globe"}, 499 + {"Name": "rules", "Icon": "shield"}, 499 500 }, 500 501 } 501 502 },
+14
appview/pages/pages.go
··· 1099 1099 return p.executeRepo("repo/settings/sites", w, params) 1100 1100 } 1101 1101 1102 + type RepoBranchRulesSettingsParams struct { 1103 + LoggedInUser *oauth.MultiAccountUser 1104 + RepoInfo repoinfo.RepoInfo 1105 + Active string 1106 + Tab string 1107 + BranchRules []tangled.RepoBranchRule 1108 + } 1109 + 1110 + func (p *Pages) RepoBranchRulesSettings(w io.Writer, params RepoBranchRulesSettingsParams) error { 1111 + params.Active = "settings" 1112 + params.Tab = "rules" 1113 + return p.executeRepo("repo/settings/branch_rules", w, params) 1114 + } 1115 + 1102 1116 type RepoIssuesParams struct { 1103 1117 LoggedInUser *oauth.MultiAccountUser 1104 1118 RepoInfo repoinfo.RepoInfo
+298
appview/pages/templates/repo/settings/branch_rules.html
··· 1 + {{ define "title" }}{{ .Tab }} settings &middot; {{ .RepoInfo.FullName }}{{ end }} 2 + 3 + {{ define "repoContent" }} 4 + <section class="w-full grid grid-cols-1 md:grid-cols-4 gap-2"> 5 + <div class="col-span-1"> 6 + {{ template "repo/settings/fragments/sidebar" . }} 7 + </div> 8 + <div class="col-span-1 md:col-span-3 flex flex-col gap-6 p-2"> 9 + {{ template "branchRulesSettings" . }} 10 + <div id="operation-error" class="text-red-500 dark:text-red-400"></div> 11 + </div> 12 + </section> 13 + {{ end }} 14 + 15 + {{ define "branchRulesSettings" }} 16 + <div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center"> 17 + <div class="col-span-1 md:col-span-2"> 18 + <h2 class="text-sm pb-2 uppercase font-bold">Branch Rules</h2> 19 + <p class="text-gray-500 dark:text-gray-400"> 20 + Branch rules restrict what can be done to branches matching a regex pattern. 21 + Use them to require pull requests, block force pushes, or prevent deletion of important branches. 22 + </p> 23 + </div> 24 + <div class="col-span-1 md:col-span-1 md:justify-self-end"> 25 + <button 26 + class="btn flex items-center gap-2" 27 + popovertarget="add-branch-rule-modal" 28 + {{ if not .RepoInfo.Roles.IsOwner }}disabled{{ end }} 29 + popovertargetaction="toggle"> 30 + {{ i "plus" "size-4" }} 31 + new rule 32 + </button> 33 + <div 34 + id="add-branch-rule-modal" 35 + popover 36 + class="bg-white w-full sm:w-[40rem] dark:bg-gray-800 p-6 max-h-dvh overflow-y-auto rounded border border-gray-200 dark:border-gray-700 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50"> 37 + {{ template "addBranchRuleModal" . }} 38 + </div> 39 + </div> 40 + </div> 41 + 42 + <div class="flex flex-col rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700 w-full"> 43 + {{ range .BranchRules }} 44 + <div class="flex flex-col gap-2 p-4"> 45 + <div class="flex items-start justify-between"> 46 + <div class="flex-1"> 47 + <div class="flex items-center gap-2"> 48 + <span class="font-semibold">{{ .Name }}</span> 49 + {{ if .Active }} 50 + <span class="inline-flex items-center gap-1 px-2 py-1 text-sm rounded bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200"> 51 + {{ i "circle-check" "size-4" }} 52 + active 53 + </span> 54 + {{ else }} 55 + <span class="inline-flex items-center gap-1 px-2 py-1 text-sm rounded bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200"> 56 + {{ i "circle" "size-4" }} 57 + inactive 58 + </span> 59 + {{ end }} 60 + </div> 61 + <div class="text-sm text-gray-500 dark:text-gray-400 mt-1"> 62 + Patterns: {{ range $i, $p := .BranchRegexPatterns }}{{ if $i }}, {{ end }}<code class="font-mono">{{ $p }}</code>{{ end }} 63 + </div> 64 + <div class="text-sm text-gray-500 dark:text-gray-400"> 65 + Blocks: {{ range $i, $a := .BlockedActions }}{{ if $i }}, {{ end }}{{ $a }}{{ end }} 66 + </div> 67 + {{ if .ExcludedDids }} 68 + <div class="text-sm text-gray-500 dark:text-gray-400"> 69 + Excluded: {{ range $i, $d := .ExcludedDids }}{{ if $i }}, {{ end }}<span class="font-mono">{{ $d }}</span>{{ end }} 70 + </div> 71 + {{ end }} 72 + </div> 73 + {{ if $.RepoInfo.Roles.IsOwner }} 74 + <div class="flex gap-2 items-center"> 75 + <button 76 + class="btn text-sm flex items-center gap-1" 77 + popovertarget="edit-branch-rule-modal-{{ .Name }}" 78 + popovertargetaction="toggle"> 79 + {{ i "pencil" "size-4" }} 80 + edit 81 + </button> 82 + <button 83 + class="btn text-sm text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 flex items-center gap-1 group" 84 + hx-delete="/{{ $.RepoInfo.FullName }}/settings/branch-rules" 85 + hx-vals='{"name": "{{ .Name }}"}' 86 + hx-swap="none" 87 + hx-confirm="Are you sure you want to delete the rule &quot;{{ .Name }}&quot;?"> 88 + {{ i "trash-2" "size-4" }} 89 + delete 90 + {{ i "loader-circle" "size-4 animate-spin hidden group-[.htmx-request]:inline" }} 91 + </button> 92 + </div> 93 + {{ end }} 94 + </div> 95 + {{ if $.RepoInfo.Roles.IsOwner }} 96 + <div 97 + id="edit-branch-rule-modal-{{ .Name }}" 98 + popover 99 + class="bg-white w-full sm:w-[40rem] dark:bg-gray-800 p-6 max-h-dvh overflow-y-auto rounded border border-gray-200 dark:border-gray-700 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50"> 100 + {{ template "editBranchRuleModal" (list $ .) }} 101 + </div> 102 + {{ end }} 103 + </div> 104 + {{ else }} 105 + <div class="flex items-center justify-center p-4 text-gray-500"> 106 + no branch rules configured yet 107 + </div> 108 + {{ end }} 109 + </div> 110 + {{ end }} 111 + 112 + {{ define "addBranchRuleModal" }} 113 + <form 114 + hx-post="/{{ $.RepoInfo.FullName }}/settings/branch-rules" 115 + hx-swap="none" 116 + class="flex flex-col gap-4" 117 + > 118 + <h3 class="uppercase font-bold">New Branch Rule</h3> 119 + 120 + <div class="flex flex-col gap-2"> 121 + <label for="branch-rule-name" class="text-sm font-semibold">Rule Name</label> 122 + <input 123 + type="text" 124 + id="branch-rule-name" 125 + name="name" 126 + required 127 + placeholder="e.g. Protect main" 128 + class="w-full" 129 + /> 130 + </div> 131 + 132 + <div class="flex flex-col gap-2"> 133 + <label for="branch-rule-patterns" class="text-sm font-semibold">Branch Patterns</label> 134 + <textarea 135 + id="branch-rule-patterns" 136 + name="branch_patterns" 137 + required 138 + rows="3" 139 + placeholder="One regex per line, e.g.&#10;^main$&#10;^release/.*" 140 + class="w-full font-mono text-sm" 141 + ></textarea> 142 + <p class="text-sm text-gray-500 dark:text-gray-400"> 143 + Go regex patterns matched against branch names. One per line. 144 + </p> 145 + </div> 146 + 147 + <div class="flex flex-col gap-2"> 148 + <label class="text-sm font-semibold">Blocked Actions</label> 149 + <div class="flex flex-col gap-2 ml-4"> 150 + <div class="flex items-center gap-2"> 151 + <input type="checkbox" id="block-direct-push" name="direct-push" value="on" checked /> 152 + <label for="block-direct-push" class="text-sm">Direct push — require pull requests instead</label> 153 + </div> 154 + <div class="flex items-center gap-2"> 155 + <input type="checkbox" id="block-force-push" name="force-push" value="on" checked /> 156 + <label for="block-force-push" class="text-sm">Force push</label> 157 + </div> 158 + <div class="flex items-center gap-2"> 159 + <input type="checkbox" id="block-delete" name="delete" value="on" /> 160 + <label for="block-delete" class="text-sm">Delete branch</label> 161 + </div> 162 + </div> 163 + </div> 164 + 165 + <div class="flex flex-col gap-2"> 166 + <label for="branch-rule-excluded-dids" class="text-sm font-semibold">Excluded DIDs <span class="font-normal text-gray-500">(optional)</span></label> 167 + <textarea 168 + id="branch-rule-excluded-dids" 169 + name="excluded_dids" 170 + rows="2" 171 + placeholder="One DID per line" 172 + class="w-full font-mono text-sm" 173 + ></textarea> 174 + <p class="text-sm text-gray-500 dark:text-gray-400"> 175 + DIDs exempt from this rule, e.g. bots or admins. 176 + </p> 177 + </div> 178 + 179 + <div class="flex items-center gap-2"> 180 + <input type="checkbox" id="branch-rule-active" name="active" value="on" checked /> 181 + <label for="branch-rule-active" class="text-sm font-semibold">Active</label> 182 + </div> 183 + 184 + <div class="flex gap-2 pt-2 justify-end"> 185 + <button 186 + type="button" 187 + popovertarget="add-branch-rule-modal" 188 + popovertargetaction="hide" 189 + class="btn flex items-center gap-2 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"> 190 + {{ i "x" "size-4" }} cancel 191 + </button> 192 + <button type="submit" class="btn-create flex items-center gap-2 group"> 193 + {{ i "plus" "size-4" }} add rule 194 + {{ i "loader-circle" "size-4 animate-spin hidden group-[.htmx-request]:inline" }} 195 + </button> 196 + </div> 197 + <div id="branch-rules-error" class="text-red-500 dark:text-red-400"></div> 198 + </form> 199 + {{ end }} 200 + 201 + {{ define "editBranchRuleModal" }} 202 + {{ $ctx := index . 0 }} 203 + {{ $rule := index . 1 }} 204 + <form 205 + hx-put="/{{ $ctx.RepoInfo.FullName }}/settings/branch-rules/{{ $rule.Name }}" 206 + hx-swap="none" 207 + class="flex flex-col gap-4" 208 + > 209 + <h3 class="uppercase font-bold">Edit Branch Rule</h3> 210 + 211 + <div class="flex flex-col gap-2"> 212 + <label for="edit-branch-rule-name-{{ $rule.Name }}" class="text-sm font-semibold">Rule Name</label> 213 + <input 214 + type="text" 215 + id="edit-branch-rule-name-{{ $rule.Name }}" 216 + name="name" 217 + required 218 + value="{{ $rule.Name }}" 219 + class="w-full" 220 + /> 221 + </div> 222 + 223 + <div class="flex flex-col gap-2"> 224 + <label for="edit-branch-rule-patterns-{{ $rule.Name }}" class="text-sm font-semibold">Branch Patterns</label> 225 + <textarea 226 + id="edit-branch-rule-patterns-{{ $rule.Name }}" 227 + name="branch_patterns" 228 + required 229 + rows="3" 230 + class="w-full font-mono text-sm" 231 + >{{ range $i, $p := $rule.BranchRegexPatterns }}{{ if $i }} 232 + {{ end }}{{ $p }}{{ end }}</textarea> 233 + <p class="text-sm text-gray-500 dark:text-gray-400"> 234 + Go regex patterns matched against branch names. One per line. 235 + </p> 236 + </div> 237 + 238 + <div class="flex flex-col gap-2"> 239 + <label class="text-sm font-semibold">Blocked Actions</label> 240 + <div class="flex flex-col gap-2 ml-4"> 241 + {{ $hasDirectPush := false }} 242 + {{ $hasForcePush := false }} 243 + {{ $hasDelete := false }} 244 + {{ range $rule.BlockedActions }} 245 + {{ if eq (print .) "direct-push" }}{{ $hasDirectPush = true }}{{ end }} 246 + {{ if eq (print .) "force-push" }}{{ $hasForcePush = true }}{{ end }} 247 + {{ if eq (print .) "delete" }}{{ $hasDelete = true }}{{ end }} 248 + {{ end }} 249 + <div class="flex items-center gap-2"> 250 + <input type="checkbox" id="edit-block-direct-push-{{ $rule.Name }}" name="direct-push" value="on" {{ if $hasDirectPush }}checked{{ end }} /> 251 + <label for="edit-block-direct-push-{{ $rule.Name }}" class="text-sm">Direct push — require pull requests instead</label> 252 + </div> 253 + <div class="flex items-center gap-2"> 254 + <input type="checkbox" id="edit-block-force-push-{{ $rule.Name }}" name="force-push" value="on" {{ if $hasForcePush }}checked{{ end }} /> 255 + <label for="edit-block-force-push-{{ $rule.Name }}" class="text-sm">Force push</label> 256 + </div> 257 + <div class="flex items-center gap-2"> 258 + <input type="checkbox" id="edit-block-delete-{{ $rule.Name }}" name="delete" value="on" {{ if $hasDelete }}checked{{ end }} /> 259 + <label for="edit-block-delete-{{ $rule.Name }}" class="text-sm">Delete branch</label> 260 + </div> 261 + </div> 262 + </div> 263 + 264 + <div class="flex flex-col gap-2"> 265 + <label for="edit-branch-rule-excluded-dids-{{ $rule.Name }}" class="text-sm font-semibold">Excluded DIDs <span class="font-normal text-gray-500">(optional)</span></label> 266 + <textarea 267 + id="edit-branch-rule-excluded-dids-{{ $rule.Name }}" 268 + name="excluded_dids" 269 + rows="2" 270 + class="w-full font-mono text-sm" 271 + >{{ range $i, $d := $rule.ExcludedDids }}{{ if $i }} 272 + {{ end }}{{ $d }}{{ end }}</textarea> 273 + <p class="text-sm text-gray-500 dark:text-gray-400"> 274 + DIDs exempt from this rule, e.g. bots or admins. 275 + </p> 276 + </div> 277 + 278 + <div class="flex items-center gap-2"> 279 + <input type="checkbox" id="edit-branch-rule-active-{{ $rule.Name }}" name="active" value="on" {{ if $rule.Active }}checked{{ end }} /> 280 + <label for="edit-branch-rule-active-{{ $rule.Name }}" class="text-sm font-semibold">Active</label> 281 + </div> 282 + 283 + <div class="flex gap-2 pt-2 justify-end"> 284 + <button 285 + type="button" 286 + popovertarget="edit-branch-rule-modal-{{ $rule.Name }}" 287 + popovertargetaction="hide" 288 + class="btn flex items-center gap-2 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"> 289 + {{ i "x" "size-4" }} cancel 290 + </button> 291 + <button type="submit" class="btn-create flex items-center gap-2 group"> 292 + {{ i "save" "size-4" }} save 293 + {{ i "loader-circle" "size-4 animate-spin hidden group-[.htmx-request]:inline" }} 294 + </button> 295 + </div> 296 + <div id="branch-rules-error" class="text-red-500 dark:text-red-400"></div> 297 + </form> 298 + {{ end }}
+5
appview/repo/router.go
··· 91 91 r.Put("/", rp.SaveRepoSiteConfig) 92 92 r.Delete("/", rp.DeleteRepoSiteConfig) 93 93 }) 94 + r.With(mw.RepoPermissionMiddleware("repo:owner")).Route("/branch-rules", func(r chi.Router) { 95 + r.Post("/", rp.CreateBranchRule) 96 + r.Put("/{name}", rp.UpdateBranchRule) 97 + r.Delete("/", rp.DeleteBranchRule) 98 + }) 94 99 r.With(mw.RepoPermissionMiddleware("repo:owner")).Route("/hooks", func(r chi.Router) { 95 100 r.Get("/", rp.Webhooks) 96 101 r.Post("/", rp.AddWebhook)
+281
appview/repo/settings.go
··· 21 21 "tangled.org/core/orm" 22 22 "tangled.org/core/types" 23 23 24 + "github.com/go-chi/chi/v5" 25 + 24 26 comatproto "github.com/bluesky-social/indigo/api/atproto" 27 + "github.com/bluesky-social/indigo/atproto/syntax" 25 28 lexutil "github.com/bluesky-social/indigo/lex/util" 26 29 indigoxrpc "github.com/bluesky-social/indigo/xrpc" 27 30 ) ··· 177 180 178 181 case "sites": 179 182 rp.sitesSettings(w, r) 183 + 184 + case "rules": 185 + rp.branchRulesSettings(w, r) 180 186 } 181 187 } 182 188 ··· 650 656 651 657 rp.pages.HxRefresh(w) 652 658 } 659 + 660 + func validateExcludedDids(dids []string) error { 661 + for _, d := range dids { 662 + if _, err := syntax.ParseDID(d); err != nil { 663 + return fmt.Errorf("invalid DID %q: %w", d, err) 664 + } 665 + } 666 + return nil 667 + } 668 + 669 + func (rp *Repo) branchRulesSettings(w http.ResponseWriter, r *http.Request) { 670 + l := rp.logger.With("handler", "branchRulesSettings") 671 + 672 + f, err := rp.repoResolver.Resolve(r) 673 + if err != nil { 674 + l.Error("failed to get repo and knot", "err", err) 675 + return 676 + } 677 + user := rp.oauth.GetMultiAccountUser(r) 678 + 679 + scheme := "http" 680 + if !rp.config.Core.Dev { 681 + scheme = "https" 682 + } 683 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 684 + xrpcc := &indigoxrpc.Client{Host: host} 685 + 686 + repo := fmt.Sprintf("%s/%s", f.Did, f.Name) 687 + raw, err := tangled.RepoListBranchRules(r.Context(), xrpcc, repo) 688 + var rules []tangled.RepoBranchRule 689 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 690 + l.Error("failed to list branch rules", "err", xrpcerr) 691 + // non-fatal: show empty list 692 + } else { 693 + var out tangled.RepoListBranchRules_Output 694 + if err := json.Unmarshal(raw, &out); err != nil { 695 + l.Error("failed to decode branch rules response", "err", err) 696 + } else { 697 + rules = out.Rules 698 + } 699 + } 700 + 701 + rp.pages.RepoBranchRulesSettings(w, pages.RepoBranchRulesSettingsParams{ 702 + LoggedInUser: user, 703 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 704 + BranchRules: rules, 705 + }) 706 + } 707 + 708 + func (rp *Repo) CreateBranchRule(w http.ResponseWriter, r *http.Request) { 709 + l := rp.logger.With("handler", "CreateBranchRule") 710 + 711 + noticeId := "branch-rules-error" 712 + 713 + f, err := rp.repoResolver.Resolve(r) 714 + if err != nil { 715 + l.Error("failed to get repo and knot", "err", err) 716 + rp.pages.Notice(w, noticeId, "Failed to load repository.") 717 + return 718 + } 719 + 720 + name := strings.TrimSpace(r.FormValue("name")) 721 + if name == "" { 722 + rp.pages.Notice(w, noticeId, "Rule name is required.") 723 + return 724 + } 725 + 726 + active := r.FormValue("active") == "on" 727 + 728 + var branchPatterns []string 729 + for _, p := range strings.Split(r.FormValue("branch_patterns"), "\n") { 730 + if p := strings.TrimSpace(p); p != "" { 731 + branchPatterns = append(branchPatterns, p) 732 + } 733 + } 734 + if len(branchPatterns) == 0 { 735 + rp.pages.Notice(w, noticeId, "At least one branch pattern is required.") 736 + return 737 + } 738 + 739 + var blockedActions []tangled.BlockedAction 740 + for _, a := range []tangled.BlockedAction{ 741 + tangled.BlockedActionDirectPush, 742 + tangled.BlockedActionForcePush, 743 + tangled.BlockedActionDelete, 744 + } { 745 + if r.FormValue(string(a)) == "on" { 746 + blockedActions = append(blockedActions, a) 747 + } 748 + } 749 + if len(blockedActions) == 0 { 750 + rp.pages.Notice(w, noticeId, "At least one blocked action is required.") 751 + return 752 + } 753 + 754 + var excludedDids []string 755 + for _, d := range strings.Split(r.FormValue("excluded_dids"), "\n") { 756 + if d := strings.TrimSpace(d); d != "" { 757 + excludedDids = append(excludedDids, d) 758 + } 759 + } 760 + if err := validateExcludedDids(excludedDids); err != nil { 761 + rp.pages.Notice(w, noticeId, err.Error()) 762 + return 763 + } 764 + 765 + client, err := rp.oauth.ServiceClient( 766 + r, 767 + oauth.WithService(f.Knot), 768 + oauth.WithLxm(tangled.RepoCreateBranchRuleNSID), 769 + oauth.WithDev(rp.config.Core.Dev), 770 + ) 771 + if err != nil { 772 + l.Error("failed to connect to knot server", "err", err) 773 + rp.pages.Notice(w, noticeId, "Failed to connect to knot server.") 774 + return 775 + } 776 + 777 + input := &tangled.RepoCreateBranchRule_Input{ 778 + Repo: f.RepoAt().String(), 779 + RepoBranchRule: tangled.RepoBranchRule{ 780 + Name: name, 781 + Active: active, 782 + BranchRegexPatterns: branchPatterns, 783 + BlockedActions: blockedActions, 784 + ExcludedDids: excludedDids, 785 + }, 786 + } 787 + 788 + if err := xrpcclient.HandleXrpcErr(tangled.RepoCreateBranchRule(r.Context(), client, input)); err != nil { 789 + l.Error("xrpc failed", "err", err) 790 + rp.pages.Notice(w, noticeId, err.Error()) 791 + return 792 + } 793 + 794 + rp.pages.HxRefresh(w) 795 + } 796 + 797 + func (rp *Repo) UpdateBranchRule(w http.ResponseWriter, r *http.Request) { 798 + l := rp.logger.With("handler", "UpdateBranchRule") 799 + 800 + noticeId := "branch-rules-error" 801 + 802 + f, err := rp.repoResolver.Resolve(r) 803 + if err != nil { 804 + l.Error("failed to get repo and knot", "err", err) 805 + rp.pages.Notice(w, noticeId, "Failed to load repository.") 806 + return 807 + } 808 + 809 + originalName := chi.URLParam(r, "name") 810 + if originalName == "" { 811 + rp.pages.Notice(w, noticeId, "Rule name is required.") 812 + return 813 + } 814 + 815 + name := strings.TrimSpace(r.FormValue("name")) 816 + if name == "" { 817 + rp.pages.Notice(w, noticeId, "Rule name is required.") 818 + return 819 + } 820 + 821 + active := r.FormValue("active") == "on" 822 + 823 + var branchPatterns []string 824 + for _, p := range strings.Split(r.FormValue("branch_patterns"), "\n") { 825 + if p := strings.TrimSpace(p); p != "" { 826 + branchPatterns = append(branchPatterns, p) 827 + } 828 + } 829 + if len(branchPatterns) == 0 { 830 + rp.pages.Notice(w, noticeId, "At least one branch pattern is required.") 831 + return 832 + } 833 + 834 + var blockedActions []tangled.BlockedAction 835 + for _, a := range []tangled.BlockedAction{ 836 + tangled.BlockedActionDirectPush, 837 + tangled.BlockedActionForcePush, 838 + tangled.BlockedActionDelete, 839 + } { 840 + if r.FormValue(string(a)) == "on" { 841 + blockedActions = append(blockedActions, a) 842 + } 843 + } 844 + if len(blockedActions) == 0 { 845 + rp.pages.Notice(w, noticeId, "At least one blocked action is required.") 846 + return 847 + } 848 + 849 + var excludedDids []string 850 + for _, d := range strings.Split(r.FormValue("excluded_dids"), "\n") { 851 + if d := strings.TrimSpace(d); d != "" { 852 + excludedDids = append(excludedDids, d) 853 + } 854 + } 855 + if err := validateExcludedDids(excludedDids); err != nil { 856 + rp.pages.Notice(w, noticeId, err.Error()) 857 + return 858 + } 859 + 860 + client, err := rp.oauth.ServiceClient( 861 + r, 862 + oauth.WithService(f.Knot), 863 + oauth.WithLxm(tangled.RepoUpdateBranchRuleNSID), 864 + oauth.WithDev(rp.config.Core.Dev), 865 + ) 866 + if err != nil { 867 + l.Error("failed to connect to knot server", "err", err) 868 + rp.pages.Notice(w, noticeId, "Failed to connect to knot server.") 869 + return 870 + } 871 + 872 + input := &tangled.RepoUpdateBranchRule_Input{ 873 + Repo: f.RepoAt().String(), 874 + OriginalName: originalName, 875 + RepoBranchRule: tangled.RepoBranchRule{ 876 + Name: name, 877 + Active: active, 878 + BranchRegexPatterns: branchPatterns, 879 + BlockedActions: blockedActions, 880 + ExcludedDids: excludedDids, 881 + }, 882 + } 883 + 884 + if err := xrpcclient.HandleXrpcErr(tangled.RepoUpdateBranchRule(r.Context(), client, input)); err != nil { 885 + l.Error("xrpc failed", "err", err) 886 + rp.pages.Notice(w, noticeId, err.Error()) 887 + return 888 + } 889 + 890 + rp.pages.HxRefresh(w) 891 + } 892 + 893 + func (rp *Repo) DeleteBranchRule(w http.ResponseWriter, r *http.Request) { 894 + l := rp.logger.With("handler", "DeleteBranchRule") 895 + 896 + noticeId := "operation-error" 897 + 898 + f, err := rp.repoResolver.Resolve(r) 899 + if err != nil { 900 + l.Error("failed to get repo and knot", "err", err) 901 + rp.pages.Notice(w, noticeId, "Failed to load repository.") 902 + return 903 + } 904 + 905 + name := r.FormValue("name") 906 + if name == "" { 907 + rp.pages.Notice(w, noticeId, "Rule name is required.") 908 + return 909 + } 910 + 911 + client, err := rp.oauth.ServiceClient( 912 + r, 913 + oauth.WithService(f.Knot), 914 + oauth.WithLxm(tangled.RepoDeleteBranchRuleNSID), 915 + oauth.WithDev(rp.config.Core.Dev), 916 + ) 917 + if err != nil { 918 + l.Error("failed to connect to knot server", "err", err) 919 + rp.pages.Notice(w, noticeId, "Failed to connect to knot server.") 920 + return 921 + } 922 + 923 + if err := xrpcclient.HandleXrpcErr(tangled.RepoDeleteBranchRule(r.Context(), client, &tangled.RepoDeleteBranchRule_Input{ 924 + Repo: f.RepoAt().String(), 925 + Name: name, 926 + })); err != nil { 927 + l.Error("xrpc failed", "err", err) 928 + rp.pages.Notice(w, noticeId, err.Error()) 929 + return 930 + } 931 + 932 + rp.pages.HxRefresh(w) 933 + }
+2 -2
flake.lock
··· 120 120 "lastModified": 1731402384, 121 121 "narHash": "sha256-OwUmrPfEehLDz0fl2ChYLK8FQM2p0G1+EMrGsYEq+6g=", 122 122 "type": "tarball", 123 - "url": "https://github.com/IBM/plex/releases/download/@ibm/plex-mono@1.1.0/ibm-plex-mono.zip" 123 + "url": "https://github.com/IBM/plex/releases/download/@ibm%2Fplex-mono@1.1.0/ibm-plex-mono.zip" 124 124 }, 125 125 "original": { 126 126 "type": "tarball", 127 - "url": "https://github.com/IBM/plex/releases/download/@ibm/plex-mono@1.1.0/ibm-plex-mono.zip" 127 + "url": "https://github.com/IBM/plex/releases/download/@ibm%2Fplex-mono@1.1.0/ibm-plex-mono.zip" 128 128 } 129 129 }, 130 130 "indigo": {
+1
flake.nix
··· 292 292 program = 293 293 (pkgs.writeShellApplication { 294 294 name = "launch-vm"; 295 + runtimeInputs = [ pkgs.git ]; 295 296 text = '' 296 297 rootDir=$(jj --ignore-working-copy root || git rev-parse --show-toplevel) || (echo "error: can't find repo root?"; exit 1) 297 298 cd "$rootDir"
+49
hook/hook.go
··· 5 5 "context" 6 6 "encoding/json" 7 7 "fmt" 8 + "io" 8 9 "net/http" 9 10 "os" 10 11 "strings" ··· 48 49 }, 49 50 Commands: []*cli.Command{ 50 51 { 52 + Name: "pre-receive", 53 + Usage: "enforces branch rules before a push is accepted (waits for stdin)", 54 + Action: preReceive, 55 + }, 56 + { 51 57 Name: "post-receive", 52 58 Usage: "sends a post-receive hook to the knot (waits for stdin)", 53 59 Action: postReceive, 54 60 }, 55 61 }, 56 62 } 63 + } 64 + 65 + func preReceive(ctx context.Context, cmd *cli.Command) error { 66 + gitDir := cmd.String("git-dir") 67 + userDid := cmd.String("user-did") 68 + userHandle := cmd.String("user-handle") 69 + endpoint := cmd.String("internal-api") 70 + 71 + payload, err := io.ReadAll(os.Stdin) 72 + if err != nil { 73 + return fmt.Errorf("failed to read stdin: %w", err) 74 + } 75 + 76 + client := &http.Client{} 77 + 78 + req, err := http.NewRequest("POST", "http://"+endpoint+"/hooks/pre-receive", strings.NewReader(string(payload))) 79 + if err != nil { 80 + return fmt.Errorf("failed to create request: %w", err) 81 + } 82 + 83 + req.Header.Set("Content-Type", "text/plain; charset=utf-8") 84 + req.Header.Set("X-Git-Dir", gitDir) 85 + req.Header.Set("X-Git-User-Did", userDid) 86 + req.Header.Set("X-Git-User-Handle", userHandle) 87 + 88 + resp, err := client.Do(req) 89 + if err != nil { 90 + return fmt.Errorf("failed to execute request: %w", err) 91 + } 92 + defer resp.Body.Close() 93 + 94 + body, _ := io.ReadAll(resp.Body) 95 + 96 + if resp.StatusCode == http.StatusForbidden { 97 + fmt.Fprintf(os.Stderr, "push rejected: %s\n", strings.TrimSpace(string(body))) 98 + os.Exit(1) 99 + } 100 + 101 + if resp.StatusCode != http.StatusOK { 102 + return fmt.Errorf("unexpected status code: %d", resp.StatusCode) 103 + } 104 + 105 + return nil 57 106 } 58 107 59 108 func postReceive(ctx context.Context, cmd *cli.Command) error {
+24 -9
hook/setup.go
··· 107 107 return fmt.Errorf("%s: %w", path, ErrNoGitRepo) 108 108 } 109 109 110 - preReceiveD := filepath.Join(path, "hooks", "post-receive.d") 110 + preReceiveD := filepath.Join(path, "hooks", "pre-receive.d") 111 111 if err := os.MkdirAll(preReceiveD, 0755); err != nil { 112 112 return fmt.Errorf("%s: %w", preReceiveD, ErrCreatingHookDir) 113 113 } 114 114 115 - notify := filepath.Join(preReceiveD, "40-notify.sh") 116 - if err := mkHook(config, notify); err != nil { 115 + branchRules := filepath.Join(preReceiveD, "30-branch-rules.sh") 116 + if err := mkHook(config, branchRules, "pre-receive"); err != nil { 117 + return fmt.Errorf("%s: %w", branchRules, ErrCreatingHook) 118 + } 119 + 120 + preReceiveDelegate := filepath.Join(path, "hooks", "pre-receive") 121 + if err := mkDelegate(preReceiveDelegate); err != nil { 122 + return fmt.Errorf("%s: %w", preReceiveDelegate, ErrCreatingDelegate) 123 + } 124 + 125 + postReceiveD := filepath.Join(path, "hooks", "post-receive.d") 126 + if err := os.MkdirAll(postReceiveD, 0755); err != nil { 127 + return fmt.Errorf("%s: %w", postReceiveD, ErrCreatingHookDir) 128 + } 129 + 130 + notify := filepath.Join(postReceiveD, "40-notify.sh") 131 + if err := mkHook(config, notify, "post-receive"); err != nil { 117 132 return fmt.Errorf("%s: %w", notify, ErrCreatingHook) 118 133 } 119 134 120 - delegate := filepath.Join(path, "hooks", "post-receive") 121 - if err := mkDelegate(delegate); err != nil { 122 - return fmt.Errorf("%s: %w", delegate, ErrCreatingDelegate) 135 + postReceiveDelegate := filepath.Join(path, "hooks", "post-receive") 136 + if err := mkDelegate(postReceiveDelegate); err != nil { 137 + return fmt.Errorf("%s: %w", postReceiveDelegate, ErrCreatingDelegate) 123 138 } 124 139 125 140 return nil 126 141 } 127 142 128 - func mkHook(config config, hookPath string) error { 143 + func mkHook(config config, hookPath, subcommand string) error { 129 144 executablePath, err := os.Executable() 130 145 if err != nil { 131 146 return err ··· 138 153 option_var="GIT_PUSH_OPTION_$i" 139 154 push_options+=(-push-option "${!option_var}") 140 155 done 141 - %s hook -git-dir "$GIT_DIR" -user-did "$GIT_USER_DID" -user-handle "$GIT_USER_HANDLE" -internal-api "%s" "${push_options[@]}" post-receive 142 - `, executablePath, config.internalApi) 156 + %s hook -git-dir "$GIT_DIR" -user-did "$GIT_USER_DID" -user-handle "$GIT_USER_HANDLE" -internal-api "%s" "${push_options[@]}" %s 157 + `, executablePath, config.internalApi, subcommand) 143 158 144 159 return os.WriteFile(hookPath, []byte(hookContent), 0755) 145 160 }
+130
knotserver/db/branch_rules.go
··· 1 + package db 2 + 3 + import ( 4 + "encoding/json" 5 + "regexp" 6 + 7 + "tangled.org/core/api/tangled" 8 + ) 9 + 10 + type BranchRule struct { 11 + Repo string 12 + tangled.RepoBranchRule 13 + } 14 + 15 + func (d *DB) GetBranchRules(repo string) ([]BranchRule, error) { 16 + rows, err := d.db.Query( 17 + `select repo, name, active, branch_patterns, blocked_actions, excluded_dids from branch_rules where repo = ?`, 18 + repo, 19 + ) 20 + if err != nil { 21 + return nil, err 22 + } 23 + defer rows.Close() 24 + 25 + var rules []BranchRule 26 + for rows.Next() { 27 + var rule BranchRule 28 + var branchPatterns, blockedActions, excludedDids []byte 29 + if err := rows.Scan(&rule.Repo, &rule.Name, &rule.Active, &branchPatterns, &blockedActions, &excludedDids); err != nil { 30 + return nil, err 31 + } 32 + if err := json.Unmarshal(branchPatterns, &rule.BranchRegexPatterns); err != nil { 33 + return nil, err 34 + } 35 + if err := json.Unmarshal(blockedActions, &rule.BlockedActions); err != nil { 36 + return nil, err 37 + } 38 + if excludedDids != nil { 39 + if err := json.Unmarshal(excludedDids, &rule.ExcludedDids); err != nil { 40 + return nil, err 41 + } 42 + } 43 + rules = append(rules, rule) 44 + } 45 + 46 + return rules, rows.Err() 47 + } 48 + 49 + // GetMatchingRulesForPush returns active branch rules that match branchName 50 + // and do not exclude userDid. 51 + func (d *DB) GetMatchingRulesForPush(repo, branchName, userDid string) ([]BranchRule, error) { 52 + rules, err := d.GetBranchRules(repo) 53 + if err != nil { 54 + return nil, err 55 + } 56 + 57 + var matching []BranchRule 58 + for _, rule := range rules { 59 + if !rule.Active { 60 + continue 61 + } 62 + 63 + excluded := false 64 + for _, did := range rule.ExcludedDids { 65 + if did == userDid { 66 + excluded = true 67 + break 68 + } 69 + } 70 + if excluded { 71 + continue 72 + } 73 + 74 + for _, pattern := range rule.BranchRegexPatterns { 75 + re, err := regexp.Compile(pattern) 76 + if err != nil { 77 + continue 78 + } 79 + if re.MatchString(branchName) { 80 + matching = append(matching, rule) 81 + break 82 + } 83 + } 84 + } 85 + 86 + return matching, nil 87 + } 88 + 89 + func (d *DB) DeleteBranchRule(repo, name string) error { 90 + _, err := d.db.Exec(`delete from branch_rules where repo = ? and name = ?`, repo, name) 91 + return err 92 + } 93 + 94 + func (d *DB) UpdateBranchRule(oldName string, rule BranchRule) error { 95 + branchPatterns, err := json.Marshal(rule.BranchRegexPatterns) 96 + if err != nil { 97 + return err 98 + } 99 + blockedActions, err := json.Marshal(rule.BlockedActions) 100 + if err != nil { 101 + return err 102 + } 103 + excludedDids, err := json.Marshal(rule.ExcludedDids) 104 + if err != nil { 105 + return err 106 + } 107 + 108 + query := `update branch_rules set name = ?, active = ?, branch_patterns = ?, blocked_actions = ?, excluded_dids = ? where repo = ? and name = ?` 109 + _, err = d.db.Exec(query, rule.Name, rule.Active, branchPatterns, blockedActions, excludedDids, rule.Repo, oldName) 110 + return err 111 + } 112 + 113 + func (d *DB) AddBranchRule(rule BranchRule) error { 114 + branchPatterns, err := json.Marshal(rule.BranchRegexPatterns) 115 + if err != nil { 116 + return err 117 + } 118 + blockedActions, err := json.Marshal(rule.BlockedActions) 119 + if err != nil { 120 + return err 121 + } 122 + excludedDids, err := json.Marshal(rule.ExcludedDids) 123 + if err != nil { 124 + return err 125 + } 126 + 127 + query := `insert into branch_rules (repo, name, active, branch_patterns, blocked_actions, excluded_dids) values (?, ?, ?, ?, ?, ?)` 128 + _, err = d.db.Exec(query, rule.Repo, rule.Name, rule.Active, branchPatterns, blockedActions, excludedDids) 129 + return err 130 + }
+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
+94
knotserver/internal.go
··· 7 7 "fmt" 8 8 "log/slog" 9 9 "net/http" 10 + "os/exec" 10 11 "path/filepath" 11 12 "strings" 12 13 ··· 409 410 return nil 410 411 } 411 412 413 + func (h *InternalHandle) PreReceiveHook(w http.ResponseWriter, r *http.Request) { 414 + l := h.l.With("handler", "PreReceiveHook") 415 + 416 + gitAbsoluteDir := r.Header.Get("X-Git-Dir") 417 + gitRelativeDir, err := filepath.Rel(h.c.Repo.ScanPath, gitAbsoluteDir) 418 + if err != nil { 419 + l.Error("failed to calculate relative git dir", "scanPath", h.c.Repo.ScanPath, "gitAbsoluteDir", gitAbsoluteDir) 420 + w.WriteHeader(http.StatusInternalServerError) 421 + fmt.Fprintln(w, "internal error") 422 + return 423 + } 424 + 425 + parts := strings.SplitN(gitRelativeDir, "/", 2) 426 + if len(parts) != 2 { 427 + l.Error("invalid git dir", "gitRelativeDir", gitRelativeDir) 428 + w.WriteHeader(http.StatusInternalServerError) 429 + fmt.Fprintln(w, "internal error") 430 + return 431 + } 432 + repoDid := parts[0] 433 + repoName := parts[1] 434 + gitUserDid := r.Header.Get("X-Git-User-Did") 435 + 436 + lines, err := git.ParsePostReceive(r.Body) 437 + if err != nil { 438 + l.Error("failed to parse pre-receive payload", "err", err) 439 + w.WriteHeader(http.StatusInternalServerError) 440 + fmt.Fprintln(w, "internal error") 441 + return 442 + } 443 + 444 + didSlashRepo, _ := securejoin.SecureJoin(repoDid, repoName) 445 + repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didSlashRepo) 446 + 447 + for _, line := range lines { 448 + refName := plumbing.ReferenceName(line.Ref) 449 + if !refName.IsBranch() { 450 + continue 451 + } 452 + branchName := refName.Short() 453 + 454 + rules, err := h.db.GetMatchingRulesForPush(didSlashRepo, branchName, gitUserDid) 455 + if err != nil { 456 + l.Error("failed to get branch rules", "err", err, "repo", didSlashRepo, "branch", branchName) 457 + continue 458 + } 459 + 460 + for _, rule := range rules { 461 + for _, action := range rule.BlockedActions { 462 + switch action { 463 + case tangled.BlockedActionDelete: 464 + if line.NewSha.IsZero() { 465 + http.Error(w, fmt.Sprintf("branch %q is protected: deletion is not allowed (rule: %s)", branchName, rule.Name), http.StatusForbidden) 466 + return 467 + } 468 + case tangled.BlockedActionForcePush: 469 + if !line.OldSha.IsZero() && !line.NewSha.IsZero() { 470 + forcePush, err := isForcePush(repoPath, line.OldSha.String(), line.NewSha.String()) 471 + if err != nil { 472 + l.Error("failed to check for force push", "err", err, "repo", didSlashRepo, "branch", branchName) 473 + } else if forcePush { 474 + http.Error(w, fmt.Sprintf("branch %q is protected: force push is not allowed (rule: %s)", branchName, rule.Name), http.StatusForbidden) 475 + return 476 + } 477 + } 478 + case tangled.BlockedActionDirectPush: 479 + if !line.NewSha.IsZero() { 480 + http.Error(w, fmt.Sprintf("branch %q is protected: direct push is not allowed (rule: %s)", branchName, rule.Name), http.StatusForbidden) 481 + return 482 + } 483 + } 484 + } 485 + } 486 + } 487 + 488 + w.WriteHeader(http.StatusOK) 489 + } 490 + 491 + func isForcePush(repoPath, oldSha, newSha string) (bool, error) { 492 + cmd := exec.Command("git", "merge-base", "--is-ancestor", oldSha, newSha) 493 + cmd.Dir = repoPath 494 + err := cmd.Run() 495 + if err == nil { 496 + return false, nil 497 + } 498 + var exitErr *exec.ExitError 499 + if errors.As(err, &exitErr) && exitErr.ExitCode() == 1 { 500 + return true, nil 501 + } 502 + return false, err 503 + } 504 + 412 505 func Internal(ctx context.Context, c *config.Config, db *db.DB, e *rbac.Enforcer, n *notifier.Notifier) http.Handler { 413 506 r := chi.NewRouter() 414 507 l := log.FromContext(ctx) ··· 427 520 r.Get("/push-allowed", h.PushAllowed) 428 521 r.Get("/keys", h.InternalKeys) 429 522 r.Get("/guard", h.Guard) 523 + r.Post("/hooks/pre-receive", h.PreReceiveHook) 430 524 r.Post("/hooks/post-receive", h.PostReceiveHook) 431 525 r.Mount("/debug", middleware.Profiler()) 432 526
+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)

History

2 rounds 0 comments
sign up or login to add to the discussion
5 commits
expand
api/tangled: add types for branch rules
knotserver/xrpc,db: add table and crud endpoints for branch rules
appview/pages,oauth: add ux and validation for managing branch rules in settings
flake: add git as knot dependency
knotserver/hook: add git hook for checking branch rules
merge conflicts detected
expand
  • knotserver/internal.go:7
expand 0 comments
5 commits
expand
api/tangled: add types for branch rules
knotserver/xrpc,db: add table and crud endpoints for branch rules
appview/pages,oauth: add ux and validation for managing branch rules in settings
flake: add git as knot dependency
knotserver/hook: add git hook for checking branch rules
expand 0 comments