Repo website setting and compact dashboard pins !38
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 | ||
| 494 | func 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 | |||
| 488 | func runGitDaemon(c *Ctx, args []string) int { | 523 | func 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 | ||
| 145 | func (s *Server) explore(w http.ResponseWriter, r *http.Request) { | 145 | func (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 | } |
| 344 | ul.repolist li { padding: var(--sp-3) var(--sp-4); border-bottom: 1px solid var(--faint); } | 344 | ul.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 */ | ||
| 346 | ul.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 | } | ||
| 354 | ul.pinlist li { | ||
| 355 | border: 1px solid var(--line); | ||
| 356 | border-radius: var(--r-md); | ||
| 357 | padding: var(--sp-1) var(--sp-3); | ||
| 358 | } | ||
| 359 | ul.pinlist li:hover { background: var(--surface); } | ||
| 360 | ul.pinlist li a { color: var(--fg); } | ||
| 361 | ul.pinlist li a:hover { color: var(--accent); text-decoration: none; } | ||
| 362 | ul.pinlist .sep { color: var(--muted); margin: 0 0.15em; } | ||
| 345 | ul.repolist li:last-child { border-bottom: none; } | 363 | ul.repolist li:last-child { border-bottom: none; } |
| 346 | ul.repolist li:hover { background: var(--surface); } | 364 | ul.repolist li:hover { background: var(--surface); } |
| 347 | ul.repolist li.empty { color: var(--muted); } | 365 | ul.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"> |