Repo website setting and compact dashboard pins !38

merged merged by cmc on 2026-08-25 15:24 UTC · krz/gitbay:repo-website into main

7 files changed, +93 −6

Layout: unified · split

e2e/description_test.go +32
@@ -55,6 +55,38 @@ func TestRepoDescriptions(t *testing.T) {
55 } 55 }
56 } 56 }
57 57
58 // Website: set, shown in show and on the repo page; bad scheme and
59 // non-admins refused.
60 if _, errOut, code := inst.ssh(t, aliceKey, "",
61 "repo", "settings", "website", "alice/tool", "https://tool.example.org"); code != 0 {
62 t.Fatalf("settings website: %s", errOut)
63 }
64 out, _, _ = inst.ssh(t, aliceKey, "", "repo", "show", "alice/tool", "--json")
65 if !strings.Contains(out, `"website":"https://tool.example.org"`) {
66 t.Fatalf("website show: %s", out)
67 }
68 if _, body := inst.get(t, "/alice/tool"); !strings.Contains(body, `href="https://tool.example.org"`) {
69 t.Fatalf("website missing on repo page:\n%s", body)
70 }
71 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "settings", "website", "alice/tool", "javascript:alert(1)"); code != 2 {
72 t.Fatalf("bad scheme accepted")
73 }
74 if _, _, code := inst.ssh(t, bobKey, "", "repo", "settings", "website", "alice/tool", "https://x.example"); code != 4 {
75 t.Fatalf("non-admin set website: want exit 4")
76 }
77 // Clearing removes it.
78 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "settings", "website", "alice/tool", "''"); code != 0 {
79 t.Fatal("website clear failed")
80 }
81 out, _, _ = inst.ssh(t, aliceKey, "", "repo", "show", "alice/tool", "--json")
82 if strings.Contains(out, `"website"`) {
83 t.Fatalf("website not cleared: %s", out)
84 }
85 if _, _, code := inst.ssh(t, aliceKey, "",
86 "repo", "settings", "website", "alice/tool", "https://tool.example.org"); code != 0 {
87 t.Fatal("website re-set failed")
88 }
89
58 // Forks inherit the description. 90 // Forks inherit the description.
59 if _, errOut, code := inst.ssh(t, bobKey, "", "repo", "fork", "alice/tool"); code != 0 { 91 if _, errOut, code := inst.ssh(t, bobKey, "", "repo", "fork", "alice/tool"); code != 0 {
60 t.Fatalf("fork: %s", errOut) 92 t.Fatalf("fork: %s", errOut)
internal/control/repo.go +37 −2
@@ -48,6 +48,8 @@ func init() {
48 Summary: "unprotect a branch: repo settings unprotect <owner/name> <branch>", Run: runUnprotect}) 48 Summary: "unprotect a branch: repo settings unprotect <owner/name> <branch>", Run: runUnprotect})
49 register(Command{Path: []string{"repo", "settings", "description"}, 49 register(Command{Path: []string{"repo", "settings", "description"},
50 Summary: "set the repository description: repo settings description <owner/name> <text> ('' clears)", Run: runSetDescription}) 50 Summary: "set the repository description: repo settings description <owner/name> <text> ('' clears)", Run: runSetDescription})
51 register(Command{Path: []string{"repo", "settings", "website"},
52 Summary: "set the repository website: repo settings website <owner/name> <url> ('' clears)", Run: runSetWebsite})
51 register(Command{Path: []string{"repo", "settings", "git-daemon"}, 53 register(Command{Path: []string{"repo", "settings", "git-daemon"},
52 Summary: "expose over git://: repo settings git-daemon <owner/name> on|off", Run: runGitDaemon}) 54 Summary: "expose over git://: repo settings git-daemon <owner/name> on|off", Run: runGitDaemon})
53 register(Command{Path: []string{"repo", "archive"}, 55 register(Command{Path: []string{"repo", "archive"},
@@ -236,6 +238,7 @@ func runRepoShow(c *Ctx, args []string) int {
236 type out struct { 238 type out struct {
237 Path string `json:"path"` 239 Path string `json:"path"`
238 Description string `json:"description,omitempty"` 240 Description string `json:"description,omitempty"`
241 Website string `json:"website,omitempty"`
239 Visibility string `json:"visibility"` 242 Visibility string `json:"visibility"`
240 DefaultBranch string `json:"default_branch"` 243 DefaultBranch string `json:"default_branch"`
241 ProtectedBranches []string `json:"protected_branches,omitempty"` 244 ProtectedBranches []string `json:"protected_branches,omitempty"`
@@ -248,8 +251,8 @@ func runRepoShow(c *Ctx, args []string) int {
248 if err != nil { 251 if err != nil {
249 return c.fail(protocol.ExitFailure, "%v", err) 252 return c.fail(protocol.ExitFailure, "%v", err)
250 } 253 }
251 d := out{repo.Path(), desc, repo.Visibility, repo.DefaultBranch, repo.Settings.ProtectedBranches, 254 d := out{repo.Path(), desc, repo.Settings.Website, repo.Visibility, repo.DefaultBranch,
252 repo.Settings.Archived, topics, nil} 255 repo.Settings.ProtectedBranches, repo.Settings.Archived, topics, nil}
253 // Mirror status is admin-only, like repo mirror list. The token never 256 // Mirror status is admin-only, like repo mirror list. The token never
254 // leaves the server. 257 // leaves the server.
255 if grant, err := c.Store.AccessRole(repo.ID, c.User.ID); err == nil && policy.CanAdmin(c.User, repo, grant) { 258 if grant, err := c.Store.AccessRole(repo.ID, c.User.ID); err == nil && policy.CanAdmin(c.User, repo, grant) {
@@ -270,6 +273,9 @@ func runRepoShow(c *Ctx, args []string) int {
270 if d.Description != "" { 273 if d.Description != "" {
271 fmt.Fprintf(w, "%s\n", d.Description) 274 fmt.Fprintf(w, "%s\n", d.Description)
272 } 275 }
276 if d.Website != "" {
277 fmt.Fprintf(w, "website: %s\n", d.Website)
278 }
273 if len(d.Topics) > 0 { 279 if len(d.Topics) > 0 {
274 fmt.Fprintf(w, "topics: %s\n", strings.Join(d.Topics, ", ")) 280 fmt.Fprintf(w, "topics: %s\n", strings.Join(d.Topics, ", "))
275 } 281 }
@@ -485,6 +491,35 @@ func runSetDescription(c *Ctx, args []string) int {
485 }) 491 })
486} 492}
487 493
494func runSetWebsite(c *Ctx, args []string) int {
495 if len(args) != 2 {
496 return c.fail(protocol.ExitUsage, "usage: repo settings website <owner/name> <url>")
497 }
498 site := strings.TrimSpace(args[1])
499 if err := validateWebsite(site); err != nil {
500 return c.fail(protocol.ExitUsage, "%v", err)
501 }
502 if len(site) > 256 {
503 return c.fail(protocol.ExitUsage, "website URL too long (max 256)")
504 }
505 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
506 if code >= 0 {
507 return code
508 }
509 s := repo.Settings
510 s.Website = site
511 if err := c.Store.SetRepoSettings(repo.ID, s); err != nil {
512 return c.fail(protocol.ExitFailure, "%v", err)
513 }
514 return c.emit(map[string]string{"website": site}, func(w io.Writer) {
515 if site == "" {
516 fmt.Fprintf(w, "website cleared on %s\n", repo.Path())
517 } else {
518 fmt.Fprintf(w, "website set on %s\n", repo.Path())
519 }
520 })
521}
522
488func runGitDaemon(c *Ctx, args []string) int { 523func runGitDaemon(c *Ctx, args []string) int {
489 if len(args) != 2 || (args[1] != "on" && args[1] != "off") { 524 if len(args) != 2 || (args[1] != "on" && args[1] != "off") {
490 return c.fail(protocol.ExitUsage, "usage: repo settings git-daemon <owner/name> on|off") 525 return c.fail(protocol.ExitUsage, "usage: repo settings git-daemon <owner/name> on|off")
internal/httpd/web.go +2 −2
@@ -136,10 +136,10 @@ func (s *Server) dashboard(w http.ResponseWriter, r *http.Request, viewer store.
136 s.render(w, "dashboard.html", struct { 136 s.render(w, "dashboard.html", struct {
137 Site string 137 Site string
138 Viewer string 138 Viewer string
139 Pinned []describedRepo 139 Pinned []store.Repo
140 MRs []store.DashboardItem 140 MRs []store.DashboardItem
141 Issues []store.DashboardItem 141 Issues []store.DashboardItem
142 }{s.siteName(), viewer.Username, s.describeAll(visible), mrs, issues}) 142 }{s.siteName(), viewer.Username, visible, mrs, issues})
143} 143}
144 144
145func (s *Server) explore(w http.ResponseWriter, r *http.Request) { 145func (s *Server) explore(w http.ResponseWriter, r *http.Request) {
internal/store/repos.go +1
@@ -28,6 +28,7 @@ type RepoSettings struct {
28 RequireResolved bool `json:"require_resolved,omitempty"` 28 RequireResolved bool `json:"require_resolved,omitempty"`
29 GitDaemon bool `json:"git_daemon,omitempty"` 29 GitDaemon bool `json:"git_daemon,omitempty"`
30 Archived bool `json:"archived,omitempty"` 30 Archived bool `json:"archived,omitempty"`
31 Website string `json:"website,omitempty"`
31} 32}
32 33
33// Path returns the canonical owner/name form. 34// Path returns the canonical owner/name form.
internal/web/static/style.css +18
@@ -342,6 +342,24 @@ ul.repolist {
342 overflow: hidden; 342 overflow: hidden;
343} 343}
344ul.repolist li { padding: var(--sp-3) var(--sp-4); border-bottom: 1px solid var(--faint); } 344ul.repolist li { padding: var(--sp-3) var(--sp-4); border-bottom: 1px solid var(--faint); }
345/* pinned repos: a compact wrap of name pills, no description or meta */
346ul.pinlist {
347 list-style: none;
348 margin: var(--sp-3) 0 var(--sp-5);
349 padding: 0;
350 display: flex;
351 flex-wrap: wrap;
352 gap: var(--sp-2);
353}
354ul.pinlist li {
355 border: 1px solid var(--line);
356 border-radius: var(--r-md);
357 padding: var(--sp-1) var(--sp-3);
358}
359ul.pinlist li:hover { background: var(--surface); }
360ul.pinlist li a { color: var(--fg); }
361ul.pinlist li a:hover { color: var(--accent); text-decoration: none; }
362ul.pinlist .sep { color: var(--muted); margin: 0 0.15em; }
345ul.repolist li:last-child { border-bottom: none; } 363ul.repolist li:last-child { border-bottom: none; }
346ul.repolist li:hover { background: var(--surface); } 364ul.repolist li:hover { background: var(--surface); }
347ul.repolist li.empty { color: var(--muted); } 365ul.repolist li.empty { color: var(--muted); }
internal/web/templates/dashboard.html +2 −2
@@ -6,8 +6,8 @@
6<p class="toolbar">logged in as <a href="/{{.Viewer}}">{{.Viewer}}</a></p> 6<p class="toolbar">logged in as <a href="/{{.Viewer}}">{{.Viewer}}</a></p>
7</div> 7</div>
8{{if .Pinned}}<h2>pinned</h2> 8{{if .Pinned}}<h2>pinned</h2>
9<ul class="repolist"> 9<ul class="pinlist">
10{{range .Pinned}}{{template "reporow" .}} 10{{range .Pinned}}<li><a href="/{{.OwnerName}}/{{.Name}}">{{.OwnerName}}<span class="sep">/</span><strong>{{.Name}}</strong></a>{{if eq .Visibility "private"}} <span class="chip chip-neutral">private</span>{{end}}</li>
11{{end}} 11{{end}}
12</ul>{{end}} 12</ul>{{end}}
13<h2>open merge requests <span class="count">{{len .MRs}}</span></h2> 13<h2>open merge requests <span class="count">{{len .MRs}}</span></h2>
internal/web/templates/layout.html +1
@@ -31,6 +31,7 @@
31<div class="repohead"> 31<div class="repohead">
32<h1 class="repotitle"><a class="owner" href="/{{.Repo.OwnerName}}">{{.Repo.OwnerName}}</a><span class="sep">/</span><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}">{{.Repo.Name}}</a>{{if eq .Repo.Visibility "private"}} <span class="chip chip-neutral">private</span>{{end}}{{if .Repo.Settings.Archived}} <span class="chip chip-stale">archived</span>{{end}}{{if .Viewer}}<form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/pin" class="inline pinform"><button type="submit" class="pinbtn{{if .Pinned}} pinned{{end}}" title="{{if .Pinned}}unpin from dashboard{{else}}pin to dashboard{{end}}">{{if .Pinned}}★ pinned{{else}}☆ pin{{end}}</button></form>{{end}}</h1> 32<h1 class="repotitle"><a class="owner" href="/{{.Repo.OwnerName}}">{{.Repo.OwnerName}}</a><span class="sep">/</span><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}">{{.Repo.Name}}</a>{{if eq .Repo.Visibility "private"}} <span class="chip chip-neutral">private</span>{{end}}{{if .Repo.Settings.Archived}} <span class="chip chip-stale">archived</span>{{end}}{{if .Viewer}}<form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/pin" class="inline pinform"><button type="submit" class="pinbtn{{if .Pinned}} pinned{{end}}" title="{{if .Pinned}}unpin from dashboard{{else}}pin to dashboard{{end}}">{{if .Pinned}}★ pinned{{else}}☆ pin{{end}}</button></form>{{end}}</h1>
33{{if .Desc}}<p class="desc">{{.Desc}}</p>{{end}} 33{{if .Desc}}<p class="desc">{{.Desc}}</p>{{end}}
34{{if .Repo.Settings.Website}}<p class="meta repowebsite"><a href="{{.Repo.Settings.Website}}" rel="nofollow">{{.Repo.Settings.Website}}</a></p>{{end}}
34{{if .Topics}}<p class="topics">{{range .Topics}}<span class="chip topic">{{.}}</span> {{end}}</p>{{end}} 35{{if .Topics}}<p class="topics">{{range .Topics}}<span class="chip topic">{{.}}</span> {{end}}</p>{{end}}
35{{range .Mirrors}}<p class="meta mirrorline">{{if eq .Direction "push"}}mirrors to{{else}}mirrors from{{end}} <a href="{{.URL}}" rel="nofollow">{{.Target}}</a>{{if .Error}} · <span class="mirrorerr">sync error: {{.Error}}</span>{{else if .Synced}} · synced {{.Synced}}{{end}}</p> 36{{range .Mirrors}}<p class="meta mirrorline">{{if eq .Direction "push"}}mirrors to{{else}}mirrors from{{end}} <a href="{{.URL}}" rel="nofollow">{{.Target}}</a>{{if .Error}} · <span class="mirrorerr">sync error: {{.Error}}</span>{{else if .Synced}} · synced {{.Synced}}{{end}}</p>
36{{end}}<nav class="tabs"> 37{{end}}<nav class="tabs">