Commit 8e64f8208b
Verified · cmc ci/build: success
e2e/settingsweb_test.go added +94
| @@ -0,0 +1,94 @@ | ||
| 1 | package e2e | |
| 2 | ||
| 3 | import ( | |
| 4 | "net/url" | |
| 5 | "strings" | |
| 6 | "testing" | |
| 7 | ) | |
| 8 | ||
| 9 | // TestRepoSettingsWeb drives the settings page: each control runs the | |
| 10 | // command the CLI runs, so repo show and settings show are the check. | |
| 11 | func TestRepoSettingsWeb(t *testing.T) { | |
| 12 | inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n") | |
| 13 | aliceKey := inst.newKey(t, "alice") | |
| 14 | bobKey := inst.newKey(t, "bob") | |
| 15 | inst.admin(t, "admin", "user", "create", "alice", | |
| 16 | "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified") | |
| 17 | inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub") | |
| 18 | if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 { | |
| 19 | t.Fatalf("repo create: %s", errOut) | |
| 20 | } | |
| 21 | ||
| 22 | alice := inst.login(t, aliceKey) | |
| 23 | set := inst.base() + "/alice/app/settings" | |
| 24 | ||
| 25 | // Only admins reach the page, and only they see the tab. | |
| 26 | if _, body := browserGet(t, alice, inst.base()+"/alice/app"); !strings.Contains(body, "/alice/app/settings") { | |
| 27 | t.Fatalf("no settings tab for the owner:\n%s", body) | |
| 28 | } | |
| 29 | if status, _ := browserGet(t, inst.login(t, bobKey), set); status != 403 && status != 404 { | |
| 30 | t.Fatalf("reader reached settings: %d", status) | |
| 31 | } | |
| 32 | ||
| 33 | post := func(v url.Values) string { | |
| 34 | t.Helper() | |
| 35 | status, body := browserPost(t, alice, set, v) | |
| 36 | if status != 200 { | |
| 37 | t.Fatalf("settings post %v: %d", v, status) | |
| 38 | } | |
| 39 | return body | |
| 40 | } | |
| 41 | ||
| 42 | post(url.Values{"field": {"description"}, "description": {"a fine tool"}}) | |
| 43 | post(url.Values{"field": {"website"}, "website": {"https://tool.example"}}) | |
| 44 | post(url.Values{"field": {"topics"}, "add": {"cli forge"}}) | |
| 45 | post(url.Values{"field": {"require-checks"}, "require-checks": {"on"}}) | |
| 46 | post(url.Values{"field": {"require-approvals"}, "approvals": {"2"}}) | |
| 47 | post(url.Values{"field": {"protect"}, "branch": {"main"}}) | |
| 48 | ||
| 49 | out, _, _ := inst.ssh(t, aliceKey, "", "repo", "show", "alice/app", "--json") | |
| 50 | for _, want := range []string{"a fine tool", "https://tool.example", `"cli"`, `"forge"`} { | |
| 51 | if !strings.Contains(out, want) { | |
| 52 | t.Fatalf("repo show missing %q:\n%s", want, out) | |
| 53 | } | |
| 54 | } | |
| 55 | out, _, _ = inst.ssh(t, aliceKey, "", "repo", "settings", "show", "alice/app", "--json") | |
| 56 | for _, want := range []string{`"require_checks":true`, `"require_approvals":2`, `"main"`} { | |
| 57 | if !strings.Contains(out, want) { | |
| 58 | t.Fatalf("settings show missing %q:\n%s", want, out) | |
| 59 | } | |
| 60 | } | |
| 61 | ||
| 62 | // Visibility is a new command; the web form drives it both ways. | |
| 63 | post(url.Values{"field": {"visibility"}, "visibility": {"private"}}) | |
| 64 | if out, _, _ := inst.ssh(t, aliceKey, "", "repo", "show", "alice/app", "--json"); !strings.Contains(out, `"visibility":"private"`) { | |
| 65 | t.Fatalf("not private:\n%s", out) | |
| 66 | } | |
| 67 | // A private repo disappears from anonymous surfaces. | |
| 68 | if status, _ := inst.get(t, "/alice/app"); status != 404 { | |
| 69 | t.Fatalf("private repo still public: %d", status) | |
| 70 | } | |
| 71 | post(url.Values{"field": {"visibility"}, "visibility": {"public"}}) | |
| 72 | if status, _ := inst.get(t, "/alice/app"); status != 200 { | |
| 73 | t.Fatalf("public repo not restored: %d", status) | |
| 74 | } | |
| 75 | ||
| 76 | // Archiving is reversible from the page; unchecking the box unarchives. | |
| 77 | post(url.Values{"field": {"archive"}, "archive": {"on"}}) | |
| 78 | if out, _, _ := inst.ssh(t, aliceKey, "", "repo", "show", "alice/app", "--json"); !strings.Contains(out, `"archived":true`) { | |
| 79 | t.Fatalf("not archived:\n%s", out) | |
| 80 | } | |
| 81 | post(url.Values{"field": {"archive"}}) | |
| 82 | if out, _, _ := inst.ssh(t, aliceKey, "", "repo", "show", "alice/app", "--json"); strings.Contains(out, `"archived":true`) { | |
| 83 | t.Fatalf("still archived:\n%s", out) | |
| 84 | } | |
| 85 | ||
| 86 | // Unprotecting works, and a refusal surfaces the command's message. | |
| 87 | post(url.Values{"field": {"unprotect"}, "branch": {"main"}}) | |
| 88 | if out, _, _ := inst.ssh(t, aliceKey, "", "repo", "settings", "show", "alice/app", "--json"); strings.Contains(out, `"protected_branches"`) { | |
| 89 | t.Fatalf("branch still protected:\n%s", out) | |
| 90 | } | |
| 91 | if body := post(url.Values{"field": {"website"}, "website": {"javascript:alert(1)"}}); !strings.Contains(body, `class="error"`) { | |
| 92 | t.Fatalf("bad website accepted:\n%s", body) | |
| 93 | } | |
| 94 | } | |
internal/control/repo.go +31
| @@ -48,6 +48,8 @@ func init() { | ||
| 48 | 48 | Summary: "unprotect a branch: repo settings unprotect <owner/name> <branch>", Run: runUnprotect}) |
| 49 | 49 | register(Command{Path: []string{"repo", "settings", "description"}, |
| 50 | 50 | Summary: "set the repository description: repo settings description <owner/name> <text> ('' clears)", Run: runSetDescription}) |
| 51 | register(Command{Path: []string{"repo", "settings", "visibility"}, | |
| 52 | Summary: "set repository visibility: repo settings visibility <owner/name> public|private", Run: runSetVisibility}) | |
| 51 | 53 | register(Command{Path: []string{"repo", "settings", "website"}, |
| 52 | 54 | Summary: "set the repository website: repo settings website <owner/name> <url> ('' clears)", Run: runSetWebsite}) |
| 53 | 55 | register(Command{Path: []string{"repo", "settings", "git-daemon"}, |
| @@ -532,6 +534,35 @@ func runSetWebsite(c *Ctx, args []string) int { | ||
| 532 | 534 | }) |
| 533 | 535 | } |
| 534 | 536 | |
| 537 | func runSetVisibility(c *Ctx, args []string) int { | |
| 538 | if len(args) != 2 || (args[1] != "public" && args[1] != "private") { | |
| 539 | return c.fail(protocol.ExitUsage, "usage: repo settings visibility <owner/name> public|private") | |
| 540 | } | |
| 541 | repo, code := resolveRepo(c, args[0], policy.CanAdmin) | |
| 542 | if code >= 0 { | |
| 543 | return code | |
| 544 | } | |
| 545 | if repo.Visibility == args[1] { | |
| 546 | return c.emit(map[string]string{"visibility": args[1]}, func(w io.Writer) { | |
| 547 | fmt.Fprintf(w, "%s is already %s\n", repo.Path(), args[1]) | |
| 548 | }) | |
| 549 | } | |
| 550 | if err := c.Store.SetRepoVisibility(repo.ID, args[1]); err != nil { | |
| 551 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 552 | } | |
| 553 | // Going private takes the repository off every anonymous surface, so | |
| 554 | // git:// exposure cannot outlive the change. | |
| 555 | if args[1] == "private" && repo.Settings.GitDaemon { | |
| 556 | s := repo.Settings | |
| 557 | s.GitDaemon = false | |
| 558 | c.Store.SetRepoSettings(repo.ID, s) | |
| 559 | } | |
| 560 | c.Store.Audit(c.User.ID, "repo.visibility", map[string]any{"repo": repo.ID, "visibility": args[1]}) | |
| 561 | return c.emit(map[string]string{"visibility": args[1]}, func(w io.Writer) { | |
| 562 | fmt.Fprintf(w, "%s is now %s\n", repo.Path(), args[1]) | |
| 563 | }) | |
| 564 | } | |
| 565 | ||
| 535 | 566 | func runGitDaemon(c *Ctx, args []string) int { |
| 536 | 567 | if len(args) != 2 || (args[1] != "on" && args[1] != "off") { |
| 537 | 568 | return c.fail(protocol.ExitUsage, "usage: repo settings git-daemon <owner/name> on|off") |
internal/httpd/routes.go +4
| @@ -120,6 +120,10 @@ func (s *Server) Routes() []Route { | ||
| 120 | 120 | Handler: s.checkOrigin(s.requireUser(s.issueAssignSubmit))}, |
| 121 | 121 | Route{Method: "POST", Pattern: "/{owner}/{repo}/issues/{n}/milestone", Mutating: true, |
| 122 | 122 | Handler: s.checkOrigin(s.requireUser(s.issueMilestoneSubmit))}, |
| 123 | Route{Method: "GET", Pattern: "/{owner}/{repo}/settings", | |
| 124 | Handler: s.requireUser(s.settingsForm)}, | |
| 125 | Route{Method: "POST", Pattern: "/{owner}/{repo}/settings", Mutating: true, | |
| 126 | Handler: s.checkOrigin(s.requireUser(s.settingsSubmit))}, | |
| 123 | 127 | Route{Method: "GET", Pattern: "/{owner}/{repo}/mrs/new", |
| 124 | 128 | Handler: s.requireUser(s.mrCreateForm)}, |
| 125 | 129 | Route{Method: "POST", Pattern: "/{owner}/{repo}/mrs/new", Mutating: true, |
internal/httpd/settings.go added +115
| @@ -0,0 +1,115 @@ | ||
| 1 | package httpd | |
| 2 | ||
| 3 | import ( | |
| 4 | "fmt" | |
| 5 | "net/http" | |
| 6 | "net/url" | |
| 7 | "strings" | |
| 8 | ||
| 9 | "gitbay.org/gitbay/internal/gitutil" | |
| 10 | "gitbay.org/gitbay/internal/store" | |
| 11 | ) | |
| 12 | ||
| 13 | // Repository settings for repo admins. Every control dispatches the | |
| 14 | // command the CLI runs; the page only groups them. Destructive lifecycle | |
| 15 | // — delete and transfer — stays on the CLI, where a typed confirmation | |
| 16 | // is the norm. | |
| 17 | ||
| 18 | type settingsPage struct { | |
| 19 | repoPage | |
| 20 | Topics []string | |
| 21 | Branches []gitutil.Ref | |
| 22 | Notice string | |
| 23 | } | |
| 24 | ||
| 25 | func (s *Server) settingsForm(w http.ResponseWriter, r *http.Request, u store.User) { | |
| 26 | repo, ok := s.repoForUser(w, r, u, policyCanAdmin) | |
| 27 | if !ok { | |
| 28 | return | |
| 29 | } | |
| 30 | p, ok := s.repoFor(w, r, "") | |
| 31 | if !ok { | |
| 32 | return | |
| 33 | } | |
| 34 | p.Tab = "settings" | |
| 35 | topics, _ := s.st.ListTopics(repo.ID) | |
| 36 | branches, _ := gitutil.Refs(p.Dir, "heads") | |
| 37 | s.render(w, "settings.html", settingsPage{ | |
| 38 | repoPage: p, Topics: topics, Branches: branches, | |
| 39 | Notice: r.URL.Query().Get("e"), | |
| 40 | }) | |
| 41 | } | |
| 42 | ||
| 43 | func (s *Server) settingsRedirect(w http.ResponseWriter, r *http.Request, msg string) { | |
| 44 | dest := fmt.Sprintf("/%s/%s/settings", r.PathValue("owner"), r.PathValue("repo")) | |
| 45 | if msg != "" { | |
| 46 | if len(msg) > 300 { | |
| 47 | msg = msg[:300] | |
| 48 | } | |
| 49 | dest += "?e=" + url.QueryEscape(msg) | |
| 50 | } | |
| 51 | http.Redirect(w, r, dest, http.StatusSeeOther) | |
| 52 | } | |
| 53 | ||
| 54 | // settingsSubmit routes one form to its command. Keeping the mapping in | |
| 55 | // one place makes what the page can reach obvious. | |
| 56 | func (s *Server) settingsSubmit(w http.ResponseWriter, r *http.Request, u store.User) { | |
| 57 | repo := r.PathValue("owner") + "/" + r.PathValue("repo") | |
| 58 | v := func(k string) string { return strings.TrimSpace(r.FormValue(k)) } | |
| 59 | ||
| 60 | var argv []string | |
| 61 | switch r.FormValue("field") { | |
| 62 | case "description": | |
| 63 | argv = []string{"repo", "settings", "description", repo, v("description")} | |
| 64 | case "website": | |
| 65 | argv = []string{"repo", "settings", "website", repo, v("website")} | |
| 66 | case "visibility": | |
| 67 | argv = []string{"repo", "settings", "visibility", repo, v("visibility")} | |
| 68 | case "git-daemon": | |
| 69 | argv = []string{"repo", "settings", "git-daemon", repo, onOff(v("git-daemon"))} | |
| 70 | case "require-checks": | |
| 71 | argv = []string{"repo", "settings", "require-checks", repo, onOff(v("require-checks"))} | |
| 72 | case "require-resolved": | |
| 73 | argv = []string{"repo", "settings", "require-resolved", repo, onOff(v("require-resolved"))} | |
| 74 | case "require-signed": | |
| 75 | argv = []string{"repo", "settings", "require-signed", repo, onOff(v("require-signed"))} | |
| 76 | case "require-approvals": | |
| 77 | argv = []string{"repo", "settings", "require-approvals", repo, v("approvals")} | |
| 78 | case "protect": | |
| 79 | argv = []string{"repo", "settings", "protect", repo, v("branch")} | |
| 80 | case "unprotect": | |
| 81 | argv = []string{"repo", "settings", "unprotect", repo, v("branch")} | |
| 82 | case "archive": | |
| 83 | verb := "archive" | |
| 84 | if v("archive") != "on" { | |
| 85 | verb = "unarchive" | |
| 86 | } | |
| 87 | argv = []string{"repo", verb, repo} | |
| 88 | case "topics": | |
| 89 | if add := strings.Fields(v("add")); len(add) > 0 { | |
| 90 | argv = append([]string{"repo", "topics", "add", repo}, add...) | |
| 91 | } else if rm := strings.Fields(v("remove")); len(rm) > 0 { | |
| 92 | argv = append([]string{"repo", "topics", "remove", repo}, rm...) | |
| 93 | } else { | |
| 94 | s.settingsRedirect(w, r, "name at least one topic") | |
| 95 | return | |
| 96 | } | |
| 97 | default: | |
| 98 | s.settingsRedirect(w, r, "unknown setting") | |
| 99 | return | |
| 100 | } | |
| 101 | ||
| 102 | _, msg, ok := s.runControl(u, argv) | |
| 103 | if ok { | |
| 104 | msg = "" | |
| 105 | } | |
| 106 | s.settingsRedirect(w, r, msg) | |
| 107 | } | |
| 108 | ||
| 109 | // onOff normalises a checkbox to the on|off the commands take. | |
| 110 | func onOff(v string) string { | |
| 111 | if v == "on" || v == "true" { | |
| 112 | return "on" | |
| 113 | } | |
| 114 | return "off" | |
| 115 | } | |
internal/httpd/web.go +12 −5
| @@ -231,6 +231,7 @@ type repoPage struct { | ||
| 231 | 231 | HasWiki bool |
| 232 | 232 | Host string |
| 233 | 233 | Mirrors []mirrorLine // repo admins only |
| 234 | CanAdmin bool // gates the settings tab | |
| 234 | 235 | // OpenIssues and OpenMRs are the counts on the header tabs. |
| 235 | 236 | OpenIssues int |
| 236 | 237 | OpenMRs int |
| @@ -290,8 +291,9 @@ func (s *Server) repoFor(w http.ResponseWriter, r *http.Request, ref string) (re | ||
| 290 | 291 | if viewer.ID != 0 { |
| 291 | 292 | pinned = s.st.IsPinned(viewer.ID, repo.ID) |
| 292 | 293 | } |
| 294 | canAdmin := viewer.ID != 0 && policy.CanAdmin(viewer, repo, grant) | |
| 293 | 295 | var mirrors []mirrorLine |
| 294 | if viewer.ID != 0 && policy.CanAdmin(viewer, repo, grant) { | |
| 296 | if canAdmin { | |
| 295 | 297 | ms, _ := s.st.ListMirrors(repo.ID) |
| 296 | 298 | for _, m := range ms { |
| 297 | 299 | mirrors = append(mirrors, mirrorLine{ |
| @@ -306,6 +308,7 @@ func (s *Server) repoFor(w http.ResponseWriter, r *http.Request, ref string) (re | ||
| 306 | 308 | openIssues, openMRs := s.st.OpenCounts(repo.ID) |
| 307 | 309 | return repoPage{ |
| 308 | 310 | basePage: s.baseFor(viewer), |
| 311 | CanAdmin: canAdmin, | |
| 309 | 312 | Mirrors: mirrors, |
| 310 | 313 | Pinned: pinned, |
| 311 | 314 | HasWiki: s.wikiDir(repo.OwnerName, repo.Name) != "", |
| @@ -1259,10 +1262,10 @@ func (s *Server) commit(w http.ResponseWriter, r *http.Request) { | ||
| 1259 | 1262 | s.render(w, "commit.html", struct { |
| 1260 | 1263 | repoPage |
| 1261 | 1264 | SHA, ShortSHA, AuthorName, AuthorEmail, AuthorUser, CommitterEmail, Date, Message string |
| 1262 | Parents []string | |
| 1263 | Sig sigView | |
| 1264 | Checks []store.CommitStatus | |
| 1265 | DiffLines []diffLine | |
| 1265 | Parents []string | |
| 1266 | Sig sigView | |
| 1267 | Checks []store.CommitStatus | |
| 1268 | DiffLines []diffLine | |
| 1266 | 1269 | }{p, full, full[:10], commitNames.name(parsed.AuthorEmail, parsed.AuthorName), parsed.AuthorEmail, commitUser, committerEmail, |
| 1267 | 1270 | time.Unix(parsed.AuthorUnix, 0).UTC().Format(time.RFC3339), msg, |
| 1268 | 1271 | gitutil.Parents(p.Dir, full), v, checks, lines}) |
| @@ -1573,6 +1576,10 @@ func (s *Server) archive(w http.ResponseWriter, r *http.Request) { | ||
| 1573 | 1576 | gitutil.Archive(p.Dir, ref, prefix, w) |
| 1574 | 1577 | } |
| 1575 | 1578 | |
| 1579 | func policyCanAdmin(u store.User, repo store.Repo, grant string) bool { | |
| 1580 | return policy.CanAdmin(u, repo, grant) | |
| 1581 | } | |
| 1582 | ||
| 1576 | 1583 | func policyCanRead(u store.User, repo store.Repo, grant string) bool { |
| 1577 | 1584 | return policy.CanRead(u, repo, grant) |
| 1578 | 1585 | } |
internal/store/repos.go +9
| @@ -82,6 +82,15 @@ func (s *Store) RepoByPath(path string) (Repo, error) { | ||
| 82 | 82 | return r, err |
| 83 | 83 | } |
| 84 | 84 | |
| 85 | // SetRepoVisibility switches a repository between public and private. | |
| 86 | func (s *Store) SetRepoVisibility(repoID int64, visibility string) error { | |
| 87 | if visibility != "public" && visibility != "private" { | |
| 88 | return fmt.Errorf("visibility must be public or private") | |
| 89 | } | |
| 90 | _, err := s.DB.Exec("UPDATE repos SET visibility = ? WHERE id = ?", visibility, repoID) | |
| 91 | return err | |
| 92 | } | |
| 93 | ||
| 85 | 94 | func (s *Store) SetRepoSettings(repoID int64, settings RepoSettings) error { |
| 86 | 95 | raw, err := json.Marshal(settings) |
| 87 | 96 | if err != nil { |
internal/web/static/style.css +15
| @@ -546,6 +546,21 @@ code.fullsha { color: var(--muted); overflow-wrap: anywhere; } | ||
| 546 | 546 | padding: var(--sp-2) var(--sp-3); |
| 547 | 547 | margin-bottom: var(--sp-4); |
| 548 | 548 | } |
| 549 | /* settings: one row per control, label then input then its own button */ | |
| 550 | form.setform { | |
| 551 | display: flex; | |
| 552 | flex-wrap: wrap; | |
| 553 | align-items: center; | |
| 554 | gap: var(--sp-3); | |
| 555 | padding: var(--sp-2) 0; | |
| 556 | border-bottom: 1px solid var(--faint); | |
| 557 | } | |
| 558 | form.setform label { min-width: 12rem; color: var(--muted); font-size: var(--fs-2); } | |
| 559 | form.setform input[type="text"], form.setform select { flex: 1 1 16rem; } | |
| 560 | form.setform input[type="number"] { width: 5rem; } | |
| 561 | ul.protlist { list-style: none; padding: 0; margin: var(--sp-2) 0; } | |
| 562 | ul.protlist li { padding: var(--sp-1) 0; display: flex; gap: var(--sp-3); align-items: baseline; } | |
| 563 | ||
| 549 | 564 | /* branch picker on the new merge request form */ |
| 550 | 565 | p.branchpick { |
| 551 | 566 | display: flex; |
internal/web/templates/layout.html +1
| @@ -70,6 +70,7 @@ | ||
| 70 | 70 | <a {{if eq $top "builds"}}aria-current="page" {{end}}href="/{{.OwnerName}}/{{.Name}}/builds">Builds</a> |
| 71 | 71 | <a {{if eq $top "releases"}}aria-current="page" {{end}}href="/{{.OwnerName}}/{{.Name}}/releases">Releases</a> |
| 72 | 72 | {{if field $ "HasWiki"}}<a {{if eq $top "wiki"}}aria-current="page" {{end}}href="/{{.OwnerName}}/{{.Name}}/wiki">Wiki</a>{{end}} |
| 73 | {{if field $ "CanAdmin"}}<a {{if eq $top "settings"}}aria-current="page" {{end}}href="/{{.OwnerName}}/{{.Name}}/settings">Settings</a>{{end}} | |
| 73 | 74 | </nav> |
| 74 | 75 | </header> |
| 75 | 76 | {{end}} |
internal/web/templates/settings.html added +103
| @@ -0,0 +1,103 @@ | ||
| 1 | {{define "title"}}settings · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} | |
| 2 | {{define "content"}} | |
| 3 | {{$base := printf "/%s/%s/settings" .Repo.OwnerName .Repo.Name}} | |
| 4 | <h1>Settings</h1> | |
| 5 | {{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}} | |
| 6 | ||
| 7 | <h2>Identity</h2> | |
| 8 | <form method="post" action="{{$base}}" class="setform"> | |
| 9 | <input type="hidden" name="field" value="description"> | |
| 10 | <label for="description">Description</label> | |
| 11 | <input type="text" id="description" name="description" value="{{.Desc}}" placeholder="one line, shown in listings"> | |
| 12 | <button type="submit">Save</button> | |
| 13 | </form> | |
| 14 | <form method="post" action="{{$base}}" class="setform"> | |
| 15 | <input type="hidden" name="field" value="website"> | |
| 16 | <label for="website">Website</label> | |
| 17 | <input type="text" id="website" name="website" value="{{.Repo.Settings.Website}}" placeholder="https://example.org"> | |
| 18 | <button type="submit">Save</button> | |
| 19 | </form> | |
| 20 | <form method="post" action="{{$base}}" class="setform"> | |
| 21 | <input type="hidden" name="field" value="topics"> | |
| 22 | <label for="topics-add">Topics</label> | |
| 23 | <input type="text" id="topics-add" name="add" placeholder="add, space-separated"> | |
| 24 | <input type="text" name="remove" placeholder="remove"> | |
| 25 | <button type="submit">Apply</button> | |
| 26 | </form> | |
| 27 | {{if .Topics}}<p class="meta">{{range .Topics}}<span class="chip topic">{{.}}</span> {{end}}</p>{{end}} | |
| 28 | ||
| 29 | <h2>Access</h2> | |
| 30 | <form method="post" action="{{$base}}" class="setform"> | |
| 31 | <input type="hidden" name="field" value="visibility"> | |
| 32 | <label for="visibility">Visibility</label> | |
| 33 | <select id="visibility" name="visibility"> | |
| 34 | <option value="public"{{if eq .Repo.Visibility "public"}} selected{{end}}>Public</option> | |
| 35 | <option value="private"{{if eq .Repo.Visibility "private"}} selected{{end}}>Private</option> | |
| 36 | </select> | |
| 37 | <button type="submit">Save</button> | |
| 38 | </form> | |
| 39 | <form method="post" action="{{$base}}" class="setform"> | |
| 40 | <input type="hidden" name="field" value="git-daemon"> | |
| 41 | <label for="git-daemon">Serve over git://</label> | |
| 42 | <input type="checkbox" id="git-daemon" name="git-daemon" value="on"{{if .Repo.Settings.GitDaemon}} checked{{end}}> | |
| 43 | <button type="submit">Save</button> | |
| 44 | </form> | |
| 45 | ||
| 46 | <h2>Merge gates</h2> | |
| 47 | <p class="meta">Checked before a merge, in this order: checks, approvals, resolved threads, signatures.</p> | |
| 48 | <form method="post" action="{{$base}}" class="setform"> | |
| 49 | <input type="hidden" name="field" value="require-checks"> | |
| 50 | <label for="require-checks">Require green checks</label> | |
| 51 | <input type="checkbox" id="require-checks" name="require-checks" value="on"{{if .Repo.Settings.RequireChecks}} checked{{end}}> | |
| 52 | <button type="submit">Save</button> | |
| 53 | </form> | |
| 54 | <form method="post" action="{{$base}}" class="setform"> | |
| 55 | <input type="hidden" name="field" value="require-approvals"> | |
| 56 | <label for="approvals">Required approvals</label> | |
| 57 | <input type="number" id="approvals" name="approvals" min="0" max="10" value="{{.Repo.Settings.RequireApprovals}}"> | |
| 58 | <button type="submit">Save</button> | |
| 59 | </form> | |
| 60 | <form method="post" action="{{$base}}" class="setform"> | |
| 61 | <input type="hidden" name="field" value="require-resolved"> | |
| 62 | <label for="require-resolved">Require resolved threads</label> | |
| 63 | <input type="checkbox" id="require-resolved" name="require-resolved" value="on"{{if .Repo.Settings.RequireResolved}} checked{{end}}> | |
| 64 | <button type="submit">Save</button> | |
| 65 | </form> | |
| 66 | <form method="post" action="{{$base}}" class="setform"> | |
| 67 | <input type="hidden" name="field" value="require-signed"> | |
| 68 | <label for="require-signed">Require signed commits</label> | |
| 69 | <input type="checkbox" id="require-signed" name="require-signed" value="on"{{if .Repo.Settings.RequireSignedCommits}} checked{{end}}> | |
| 70 | <button type="submit">Save</button> | |
| 71 | </form> | |
| 72 | ||
| 73 | <h2>Protected branches</h2> | |
| 74 | {{if .Repo.Settings.ProtectedBranches}} | |
| 75 | <ul class="protlist"> | |
| 76 | {{range .Repo.Settings.ProtectedBranches}}<li><code>{{.}}</code> | |
| 77 | <form method="post" action="{{$base}}" class="inline"> | |
| 78 | <input type="hidden" name="field" value="unprotect"> | |
| 79 | <input type="hidden" name="branch" value="{{.}}"> | |
| 80 | <button type="submit" class="linklike">Unprotect</button> | |
| 81 | </form></li> | |
| 82 | {{end}} | |
| 83 | </ul> | |
| 84 | {{else}}<p class="meta">No protected branches. A protected branch refuses deletion and force-pushes.</p>{{end}} | |
| 85 | <form method="post" action="{{$base}}" class="setform"> | |
| 86 | <input type="hidden" name="field" value="protect"> | |
| 87 | <label for="branch">Protect a branch</label> | |
| 88 | <select id="branch" name="branch"> | |
| 89 | {{range .Branches}}<option value="{{.Name}}">{{.Name}}</option>{{end}} | |
| 90 | </select> | |
| 91 | <button type="submit">Protect</button> | |
| 92 | </form> | |
| 93 | ||
| 94 | <h2>Lifecycle</h2> | |
| 95 | <form method="post" action="{{$base}}" class="setform"> | |
| 96 | <input type="hidden" name="field" value="archive"> | |
| 97 | <label for="archive">Archived (read-only)</label> | |
| 98 | <input type="checkbox" id="archive" name="archive" value="on"{{if .Repo.Settings.Archived}} checked{{end}}> | |
| 99 | <button type="submit">Save</button> | |
| 100 | </form> | |
| 101 | <p class="meta">Deleting or transferring a repository is a CLI operation: | |
| 102 | <code>gitbay repo delete {{.Repo.OwnerName}}/{{.Repo.Name}} --yes</code></p> | |
| 103 | {{end}} | |