Commit b4a2206ec2

b4a2206ec2e71d04b592ef40e5dd59444c522bf0

parent: e917696f47

Verified · cmc ci/build: success ci/sonar: success ci/test: success ci/vuln: success

cmc <hello@cleberg.net> · 2026-09-06 18:41 UTC

store, control, web, wiki: bookmark a repository

Bookmarks are public and counted, pins are private quick access; they
answer different questions, so they are separate tables rather than a
flag on one. repo bookmark/unbookmark/bookmarks, a control on the
repository header, the count in the facts bar, and /bookmarks.

Closes #146

Layout: unified · split

.gitbay/wiki/Parity.org +10
@@ -139,6 +139,7 @@ column and are always markdown.
139| create | yes | yes | yes | 139| create | yes | yes | yes |
140| fork | yes | yes | no | 140| fork | yes | yes | no |
141| pin | yes | yes | yes | 141| pin | yes | yes | yes |
142| bookmark, bookmark list | yes | yes | no |
142| watch, mute | yes | yes | no | 143| watch, mute | yes | yes | no |
143| settings, protection | yes | yes | yes | 144| settings, protection | yes | yes | yes |
144| topics, website | yes | yes | yes | 145| topics, website | yes | yes | yes |
@@ -168,6 +169,15 @@ within a couple of seconds. Cancelling a duplicate of a commit that
168already passed the job puts that result back on the commit. The build 169already passed the job puts that result back on the commit. The build
169page carries the button while a build is still cancellable. 170page carries the button while a build is still cancellable.
170 171
172A pin and a bookmark are different things and are stored separately. A
173pin is private quick access to what you are working on, and drives the
174rail; a bookmark is public, says a repository is worth coming back to,
175and its count is the only popularity signal on the instance. Bookmarking
176needs read access only — it is something you do to someone else's
177repository — and a repository bookmarked while public and since made
178private drops out of the listing rather than leaking that it exists
179(krz/gitbay#146).
180
171Dependency checks are off until a repository's admin turns them on: the 181Dependency checks are off until a repository's admin turns them on: the
172check tells a public registry what the repository depends on. =repo deps 182check tells a public registry what the repository depends on. =repo deps
173status= lists what is behind; the repository's settings page renders the 183status= lists what is behind; the repository's settings page renders the
.gitbay/wiki/Users.org +7
@@ -89,6 +89,13 @@ key, author line claims someone else), =signed_key_expired= /
89(web edits, merge commits) are always =unsigned= — the server holds no 89(web edits, merge commits) are always =unsigned= — the server holds no
90signing key on principle. 90signing key on principle.
91 91
92=repo bookmark <owner/name>= saves a repository to come back to and
93=repo unbookmark= drops it; =repo bookmarks= lists yours, each with how
94many people have bookmarked it. The web has the control on the
95repository header, the count in the facts bar, and the list at
96=/bookmarks=. Bookmarks are public and counted; pins are private and
97drive the rail. Read access is all a bookmark needs.
98
92* Profiles 99* Profiles
93 100
94A profile is what =/{owner}= shows: a one-line description, a website, a 101A profile is what =/{owner}= shows: a one-line description, a website, a
cmd/gitbay/main.go +3
@@ -414,6 +414,9 @@ func repoCmd() *cobra.Command {
414 passOpts{server: []string{"repo", "download"}, needsRepo: true}), 414 passOpts{server: []string{"repo", "download"}, needsRepo: true}),
415 pass("pin", "pin a repository to your dashboard", passOpts{server: []string{"repo", "pin"}, needsRepo: true}), 415 pass("pin", "pin a repository to your dashboard", passOpts{server: []string{"repo", "pin"}, needsRepo: true}),
416 pass("unpin", "unpin a repository", passOpts{server: []string{"repo", "unpin"}, needsRepo: true}), 416 pass("unpin", "unpin a repository", passOpts{server: []string{"repo", "unpin"}, needsRepo: true}),
417 pass("bookmark", "bookmark a repository to come back to", passOpts{server: []string{"repo", "bookmark"}, needsRepo: true}),
418 pass("unbookmark", "remove a bookmark", passOpts{server: []string{"repo", "unbookmark"}, needsRepo: true}),
419 pass("bookmarks", "list the repositories you have bookmarked", passOpts{server: []string{"repo", "bookmarks"}}),
417 pass("watch", "hear about all activity on a repository", passOpts{server: []string{"repo", "watch"}, needsRepo: true}), 420 pass("watch", "hear about all activity on a repository", passOpts{server: []string{"repo", "watch"}, needsRepo: true}),
418 pass("unwatch", "mute a repository", passOpts{server: []string{"repo", "unwatch"}, needsRepo: true}), 421 pass("unwatch", "mute a repository", passOpts{server: []string{"repo", "unwatch"}, needsRepo: true}),
419 pass("archive", "archive a repository (read-only)", passOpts{server: []string{"repo", "archive"}, needsRepo: true}), 422 pass("archive", "archive a repository (read-only)", passOpts{server: []string{"repo", "archive"}, needsRepo: true}),
e2e/bookmarks_test.go added +107
@@ -0,0 +1,107 @@
1package e2e
2
3import (
4 "net/url"
5 "os"
6 "path/filepath"
7 "strings"
8 "testing"
9)
10
11// Bookmarks are the public "saved for later", separate from pins: they
12// are something you do to someone else's repository, and the count is a
13// signal of what people found worth returning to (#146).
14func TestBookmarks(t *testing.T) {
15 inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n")
16 aliceKey := inst.newKey(t, "alice")
17 bobKey := inst.newKey(t, "bob")
18 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub")
19 inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub")
20 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 {
21 t.Fatalf("repo create: %s", errOut)
22 }
23 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/secret", "--private"); code != 0 {
24 t.Fatalf("repo create: %s", errOut)
25 }
26 // The facts bar, where the count shows, needs a repository with
27 // commits: an empty one has nothing to size up.
28 env := inst.gitEnv(aliceKey)
29 work := t.TempDir()
30 mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w")
31 dir := filepath.Join(work, "w")
32 os.WriteFile(filepath.Join(dir, "a.txt"), []byte("a\n"), 0o644)
33 mustGit(t, dir, env, "checkout", "-q", "-b", "main")
34 mustGit(t, dir, env, "add", ".")
35 mustGit(t, dir, env, "commit", "-q", "-m", "base")
36 mustGit(t, dir, env, "push", "-q", "origin", "main")
37
38 // Bob bookmarks someone else's repository; read access is enough.
39 if out, errOut, code := inst.ssh(t, bobKey, "", "repo", "bookmark", "alice/app"); code != 0 {
40 t.Fatalf("bookmark: %s%s", out, errOut)
41 }
42 if out, _, _ := inst.ssh(t, bobKey, "", "repo", "bookmarks", "--json"); !strings.Contains(out, `"path":"alice/app"`) ||
43 !strings.Contains(out, `"bookmarks":1`) {
44 t.Fatalf("bookmark not listed with its count:\n%s", out)
45 }
46 // Bookmarking twice is not an error and does not double the count.
47 inst.ssh(t, bobKey, "", "repo", "bookmark", "alice/app")
48 if out, _, _ := inst.ssh(t, bobKey, "", "repo", "bookmarks", "--json"); !strings.Contains(out, `"bookmarks":1`) {
49 t.Fatalf("a second bookmark changed the count:\n%s", out)
50 }
51
52 // A private repository a stranger cannot read is not found, the same
53 // as everywhere.
54 if _, _, code := inst.ssh(t, bobKey, "", "repo", "bookmark", "alice/secret"); code != 3 {
55 t.Errorf("bookmarking an invisible repository exited %d, want 3", code)
56 }
57
58 // Pins stay separate: bookmarking does not pin.
59 if out, _, _ := inst.ssh(t, bobKey, "", "dashboard", "--json"); strings.Contains(out, "alice/app") {
60 t.Errorf("a bookmark showed up as a pin:\n%s", out)
61 }
62
63 // The count is public and shows on the repository page.
64 if _, body := inst.get(t, "/alice/app"); !strings.Contains(body, "1</strong> bookmark") {
65 t.Errorf("count not on the repo page:\n%s", body)
66 }
67
68 // The web toggles it, through the same command.
69 bob := inst.login(t, bobKey)
70 if status, _ := browserPost(t, bob, inst.base()+"/alice/app/bookmark", url.Values{}); status != 200 {
71 t.Fatal("web unbookmark failed")
72 }
73 if out, _, _ := inst.ssh(t, bobKey, "", "repo", "bookmarks", "--json"); strings.Contains(out, "alice/app") {
74 t.Fatalf("still bookmarked after the toggle:\n%s", out)
75 }
76 if status, _ := browserPost(t, bob, inst.base()+"/alice/app/bookmark", url.Values{}); status != 200 {
77 t.Fatal("web bookmark failed")
78 }
79 if _, page := browserGet(t, bob, inst.base()+"/bookmarks"); !strings.Contains(page, "alice/app") {
80 t.Fatalf("bookmarks page does not list it:\n%s", page)
81 }
82
83 // Unbookmarking something that is not bookmarked says so.
84 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "unbookmark", "alice/app"); code != 3 {
85 t.Errorf("unbookmarking what was never bookmarked exited %d, want 3", code)
86 }
87}
88
89// A repository bookmarked while public and since made private drops out
90// of the listing rather than leaking its existence.
91func TestBookmarkOfRepoGonePrivate(t *testing.T) {
92 inst := startInstance(t)
93 aliceKey := inst.newKey(t, "alice")
94 bobKey := inst.newKey(t, "bob")
95 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub")
96 inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub")
97 inst.ssh(t, aliceKey, "", "repo", "create", "alice/app")
98 if _, errOut, code := inst.ssh(t, bobKey, "", "repo", "bookmark", "alice/app"); code != 0 {
99 t.Fatalf("bookmark: %s", errOut)
100 }
101 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "settings", "visibility", "alice/app", "private"); code != 0 {
102 t.Fatalf("visibility: %s", errOut)
103 }
104 if out, _, _ := inst.ssh(t, bobKey, "", "repo", "bookmarks", "--json"); strings.Contains(out, "alice/app") {
105 t.Fatalf("a repository gone private is still listed:\n%s", out)
106 }
107}
e2e/readonly_test.go +1
@@ -143,6 +143,7 @@ func TestReadOnlyCommandsWriteNothing(t *testing.T) {
143 "release show": {"alice/app", "v1"}, 143 "release show": {"alice/app", "v1"},
144 "release asset get": {"alice/app", "v1", "a.txt"}, 144 "release asset get": {"alice/app", "v1", "a.txt"},
145 "notifications list": nil, 145 "notifications list": nil,
146 "repo bookmarks": nil,
146 "search": {"app"}, 147 "search": {"app"},
147 "mr revisions": {"alice/app", "1"}, 148 "mr revisions": {"alice/app", "1"},
148 "mr range-diff": {"alice/app", "1"}, 149 "mr range-diff": {"alice/app", "1"},
internal/control/output_test.go +2 −2
@@ -16,7 +16,7 @@ func TestNamedPayloadsRoundTrip(t *testing.T) {
16 &Created{}, &MRCreated{}, &IssueShow{}, &MRShow{}, 16 &Created{}, &MRCreated{}, &IssueShow{}, &MRShow{},
17 &BuildOut{}, &JobOut{}, &ProfileOut{}, &ProfileRepo{}, &ProfileMember{}, 17 &BuildOut{}, &JobOut{}, &ProfileOut{}, &ProfileRepo{}, &ProfileMember{},
18 &DashboardOut{}, &DashboardItem{}, &DashboardBuild{}, &PinnedOut{}, 18 &DashboardOut{}, &DashboardItem{}, &DashboardBuild{}, &PinnedOut{},
19 &FeedOut{}, &ActivityDay{}, &DepsOut{}, &DepBehind{}, &ForkOut{}, 19 &FeedOut{}, &ActivityDay{}, &DepsOut{}, &DepBehind{}, &ForkOut{}, &BookmarkOut{},
20 &SearchResult{}, &ReviewOut{}, &CheckOut{}, &CommitOut{}, 20 &SearchResult{}, &ReviewOut{}, &CheckOut{}, &CommitOut{},
21 } 21 }
22 for _, p := range payloads { 22 for _, p := range payloads {
@@ -40,7 +40,7 @@ func TestPayloadFieldsAreTagged(t *testing.T) {
40 types := []any{ 40 types := []any{
41 Created{}, MRCreated{}, BuildOut{}, JobOut{}, ProfileOut{}, ProfileRepo{}, 41 Created{}, MRCreated{}, BuildOut{}, JobOut{}, ProfileOut{}, ProfileRepo{},
42 ProfileMember{}, DashboardOut{}, DashboardItem{}, DashboardBuild{}, 42 ProfileMember{}, DashboardOut{}, DashboardItem{}, DashboardBuild{},
43 PinnedOut{}, FeedOut{}, ActivityDay{}, DepsOut{}, DepBehind{}, ForkOut{}, 43 PinnedOut{}, FeedOut{}, ActivityDay{}, DepsOut{}, DepBehind{}, ForkOut{}, BookmarkOut{},
44 SearchResult{}, ReviewOut{}, 44 SearchResult{}, ReviewOut{},
45 CheckOut{}, CommitOut{}, ServerOut{}, 45 CheckOut{}, CommitOut{}, ServerOut{},
46 } 46 }
internal/control/repo.go +86
@@ -99,6 +99,15 @@ func init() {
99 register(Command{Path: []string{"repo", "unpin"}, 99 register(Command{Path: []string{"repo", "unpin"},
100 Summary: "unpin a repository", 100 Summary: "unpin a repository",
101 Usage: "repo unpin <owner/name>", Run: runRepoUnpin}) 101 Usage: "repo unpin <owner/name>", Run: runRepoUnpin})
102 register(Command{Path: []string{"repo", "bookmark"},
103 Summary: "bookmark a repository to come back to",
104 Usage: "repo bookmark <owner/name>", Run: runRepoBookmark})
105 register(Command{Path: []string{"repo", "unbookmark"},
106 Summary: "remove a bookmark",
107 Usage: "repo unbookmark <owner/name>", Run: runRepoUnbookmark})
108 register(Command{Path: []string{"repo", "bookmarks"},
109 Summary: "list the repositories you have bookmarked",
110 Usage: "repo bookmarks", ReadOnly: true, Run: runRepoBookmarks})
102} 111}
103 112
104const ( 113const (
@@ -881,6 +890,83 @@ func setPinned(c *Ctx, args []string, pin bool) int {
881 }) 890 })
882} 891}
883 892
893func runRepoBookmark(c *Ctx, args []string) int { return setBookmarked(c, args, true) }
894func runRepoUnbookmark(c *Ctx, args []string) int { return setBookmarked(c, args, false) }
895
896// setBookmarked mirrors setPinned. A bookmark needs only read access —
897// bookmarking is something you do to someone else's repository, which is
898// the whole point of it — and a private repository you cannot read is
899// not found, as everywhere.
900func setBookmarked(c *Ctx, args []string, on bool) int {
901 verb := "bookmark"
902 if !on {
903 verb = "unbookmark"
904 }
905 if len(args) != 1 {
906 return c.fail(protocol.ExitUsage, "usage: repo %s <owner/name>", verb)
907 }
908 repo, code := resolveRepo(c, args[0], policy.CanRead)
909 if code >= 0 {
910 return code
911 }
912 if on {
913 if err := c.Store.BookmarkRepo(c.User.ID, repo.ID); err != nil {
914 return c.fail(protocol.ExitFailure, "%v", err)
915 }
916 } else if err := c.Store.UnbookmarkRepo(c.User.ID, repo.ID); err != nil {
917 if errors.Is(err, store.ErrNotFound) {
918 return c.fail(protocol.ExitNotFound, "%s is not bookmarked", repo.Path())
919 }
920 return c.fail(protocol.ExitFailure, "%v", err)
921 }
922 return c.emit(map[string]string{verb + "ed": repo.Path()}, func(w io.Writer) {
923 fmt.Fprintf(w, "%sed %s\n", verb, repo.Path())
924 })
925}
926
927// BookmarkOut is one row of `repo bookmarks`: the repository and how many
928// people have bookmarked it.
929type BookmarkOut struct {
930 Path string `json:"path"`
931 Description string `json:"description,omitempty"`
932 Visibility string `json:"visibility"`
933 Bookmarks int `json:"bookmarks"`
934}
935
936func runRepoBookmarks(c *Ctx, args []string) int {
937 if len(args) != 0 {
938 return c.fail(protocol.ExitUsage, "usage: repo bookmarks")
939 }
940 repos, err := c.Store.ListBookmarks(c.User.ID)
941 if err != nil {
942 return c.fail(protocol.ExitFailure, "%v", err)
943 }
944 out := []BookmarkOut{}
945 for _, r := range repos {
946 // A repository bookmarked while public and since made private
947 // stays in the table and drops out of the listing, the same way
948 // it disappears from every other surface.
949 grant, err := c.Store.AccessRole(r.ID, c.User.ID)
950 if err != nil {
951 return c.fail(protocol.ExitFailure, "%v", err)
952 }
953 if !policy.CanRead(c.User, r, grant) {
954 continue
955 }
956 out = append(out, BookmarkOut{
957 Path: r.Path(),
958 Description: gitutil.ReadDescription(RepoDir(c.Cfg.Server.Root, r.OwnerName, r.Name)),
959 Visibility: r.Visibility,
960 Bookmarks: c.Store.BookmarkCount(r.ID),
961 })
962 }
963 return c.emit(out, func(w io.Writer) {
964 for _, b := range out {
965 fmt.Fprintf(w, "%s\t%d\t%s\n", b.Path, b.Bookmarks, b.Description)
966 }
967 })
968}
969
884func runProtect(c *Ctx, args []string) int { return setProtect(c, args, true) } 970func runProtect(c *Ctx, args []string) int { return setProtect(c, args, true) }
885func runUnprotect(c *Ctx, args []string) int { return setProtect(c, args, false) } 971func runUnprotect(c *Ctx, args []string) int { return setProtect(c, args, false) }
886 972
internal/httpd/accounts.go +29
@@ -221,6 +221,35 @@ func (s *Server) pinToggle(w http.ResponseWriter, r *http.Request, u store.User)
221 http.Redirect(w, r, "/"+repo.Path(), http.StatusSeeOther) 221 http.Redirect(w, r, "/"+repo.Path(), http.StatusSeeOther)
222} 222}
223 223
224// bookmarkToggle saves or unsaves a repository for the viewer. Read
225// access is all a bookmark needs — it is something you do to someone
226// else's repository — and repoForUser 404s a private one either way.
227func (s *Server) bookmarkToggle(w http.ResponseWriter, r *http.Request, u store.User) {
228 repo, ok := s.repoForUser(w, r, u, policy.CanRead)
229 if !ok {
230 return
231 }
232 verb := "bookmark"
233 if s.st.IsBookmarked(u.ID, repo.ID) {
234 verb = "unbookmark"
235 }
236 if _, msg, ok := s.runControl(u, []string{"repo", verb, repo.Path()}); !ok {
237 s.setFlash(w, msg)
238 }
239 http.Redirect(w, r, "/"+repo.Path(), http.StatusSeeOther)
240}
241
242// bookmarksPage lists what the viewer has saved.
243func (s *Server) bookmarksPage(w http.ResponseWriter, r *http.Request, u store.User) {
244 var rows []control.BookmarkOut
245 s.runControlInto(u, []string{"repo", "bookmarks"}, &rows)
246 s.render(w, "bookmarks.html", struct {
247 basePage
248 Tab string
249 Bookmarks []control.BookmarkOut
250 }{s.baseFor(u), "bookmarks", rows})
251}
252
224// forkSubmit forks the repository under the viewer's account and sends 253// forkSubmit forks the repository under the viewer's account and sends
225// them to it. The command decides everything that matters — read access, 254// them to it. The command decides everything that matters — read access,
226// quota, name collisions — so a refusal comes back as its own message on 255// quota, name collisions — so a refusal comes back as its own message on
internal/httpd/facts.go +2
@@ -19,6 +19,7 @@ type repoFacts struct {
19 License string 19 License string
20 Release string // newest release tag, if any 20 Release string // newest release tag, if any
21 Build string // latest build status on the default branch 21 Build string // latest build status on the default branch
22 Bookmarks int // how many people saved it, the popularity signal (#146)
22} 23}
23 24
24type factContributor struct { 25type factContributor struct {
@@ -88,5 +89,6 @@ func (s *Server) factsFor(p repoPage) repoFacts {
88 if builds, err := s.st.ListBuilds(p.Repo.ID, 1); err == nil && len(builds) > 0 { 89 if builds, err := s.st.ListBuilds(p.Repo.ID, 1); err == nil && len(builds) > 0 {
89 f.Build = builds[0].Status 90 f.Build = builds[0].Status
90 } 91 }
92 f.Bookmarks = s.st.BookmarkCount(p.Repo.ID)
91 return f 93 return f
92} 94}
internal/httpd/routes.go +3
@@ -152,6 +152,9 @@ func (s *Server) Routes() []Route {
152 Handler: s.checkOrigin(s.requireUser(s.releaseSubmit))}, 152 Handler: s.checkOrigin(s.requireUser(s.releaseSubmit))},
153 Route{Method: "POST", Pattern: "/{owner}/{repo}/labels", Mutating: true, 153 Route{Method: "POST", Pattern: "/{owner}/{repo}/labels", Mutating: true,
154 Handler: s.checkOrigin(s.requireUser(s.labelSubmit))}, 154 Handler: s.checkOrigin(s.requireUser(s.labelSubmit))},
155 Route{Method: "GET", Pattern: "/bookmarks", Handler: s.requireUser(s.bookmarksPage)},
156 Route{Method: "POST", Pattern: "/{owner}/{repo}/bookmark", Mutating: true,
157 Handler: s.checkOrigin(s.requireUser(s.bookmarkToggle))},
155 Route{Method: "POST", Pattern: "/{owner}/{repo}/fork", Mutating: true, 158 Route{Method: "POST", Pattern: "/{owner}/{repo}/fork", Mutating: true,
156 Handler: s.checkOrigin(s.requireUser(s.forkSubmit))}, 159 Handler: s.checkOrigin(s.requireUser(s.forkSubmit))},
157 Route{Method: "POST", Pattern: "/{owner}/{repo}/builds", Mutating: true, 160 Route{Method: "POST", Pattern: "/{owner}/{repo}/builds", Mutating: true,
internal/httpd/web.go +4 −1
@@ -247,6 +247,7 @@ type repoPage struct {
247 Tab string // active tab in the repo header 247 Tab string // active tab in the repo header
248 Topics []string 248 Topics []string
249 Pinned bool // by the viewer 249 Pinned bool // by the viewer
250 Marked bool // bookmarked by the viewer
250 Watch string // the viewer's watch state: watching, muted, or "" 251 Watch string // the viewer's watch state: watching, muted, or ""
251 HasWiki bool 252 HasWiki bool
252 Host string 253 Host string
@@ -307,9 +308,10 @@ func (s *Server) repoFor(w http.ResponseWriter, r *http.Request, ref string) (re
307 ref = repo.DefaultBranch 308 ref = repo.DefaultBranch
308 } 309 }
309 topics, _ := s.st.ListTopics(repo.ID) 310 topics, _ := s.st.ListTopics(repo.ID)
310 pinned, watch := false, "" 311 pinned, marked, watch := false, false, ""
311 if viewer.ID != 0 { 312 if viewer.ID != 0 {
312 pinned = s.st.IsPinned(viewer.ID, repo.ID) 313 pinned = s.st.IsPinned(viewer.ID, repo.ID)
314 marked = s.st.IsBookmarked(viewer.ID, repo.ID)
313 watch = s.st.RepoWatchState(repo.ID, viewer.ID) 315 watch = s.st.RepoWatchState(repo.ID, viewer.ID)
314 } 316 }
315 canAdmin := viewer.ID != 0 && policy.CanAdmin(viewer, repo, grant) 317 canAdmin := viewer.ID != 0 && policy.CanAdmin(viewer, repo, grant)
@@ -332,6 +334,7 @@ func (s *Server) repoFor(w http.ResponseWriter, r *http.Request, ref string) (re
332 CanAdmin: canAdmin, 334 CanAdmin: canAdmin,
333 Mirrors: mirrors, 335 Mirrors: mirrors,
334 Pinned: pinned, 336 Pinned: pinned,
337 Marked: marked,
335 Watch: watch, 338 Watch: watch,
336 HasWiki: s.hasWiki(repo), 339 HasWiki: s.hasWiki(repo),
337 Host: s.cfg.SiteHost(), 340 Host: s.cfg.SiteHost(),
internal/policy/names.go +1
@@ -14,6 +14,7 @@ var reservedNames = map[string]bool{
14 "admin": true, 14 "admin": true,
15 "api": true, 15 "api": true,
16 "archive": true, 16 "archive": true,
17 "bookmarks": true,
17 "explore": true, 18 "explore": true,
18 "favicon.svg": true, 19 "favicon.svg": true,
19 "gitbay": true, // vanity go-import path on gitbay.org 20 "gitbay": true, // vanity go-import path on gitbay.org
internal/store/bookmarks.go added +62
@@ -0,0 +1,62 @@
1package store
2
3// Bookmarks are public and counted; pins are private quick access. The
4// two are deliberately separate tables rather than a flag on one, because
5// they answer different questions: "what am I working on" and "what is
6// worth coming back to" (#146).
7
8func (s *Store) BookmarkRepo(userID, repoID int64) error {
9 _, err := s.DB.Exec(
10 "INSERT INTO repo_bookmarks (user_id, repo_id) VALUES (?, ?) ON CONFLICT DO NOTHING",
11 userID, repoID)
12 return err
13}
14
15func (s *Store) UnbookmarkRepo(userID, repoID int64) error {
16 res, err := s.DB.Exec(
17 "DELETE FROM repo_bookmarks WHERE user_id = ? AND repo_id = ?", userID, repoID)
18 if err != nil {
19 return err
20 }
21 if n, _ := res.RowsAffected(); n == 0 {
22 return ErrNotFound
23 }
24 return nil
25}
26
27func (s *Store) IsBookmarked(userID, repoID int64) bool {
28 var n int
29 s.DB.QueryRow("SELECT COUNT(*) FROM repo_bookmarks WHERE user_id = ? AND repo_id = ?",
30 userID, repoID).Scan(&n)
31 return n > 0
32}
33
34// BookmarkCount is how many people have bookmarked a repository. It is
35// public: the count is the point, and it names nobody.
36func (s *Store) BookmarkCount(repoID int64) int {
37 var n int
38 s.DB.QueryRow("SELECT COUNT(*) FROM repo_bookmarks WHERE repo_id = ?", repoID).Scan(&n)
39 return n
40}
41
42// ListBookmarks returns one user's bookmarked repositories, newest first.
43// The caller filters by what the viewer may see: a bookmarked repository
44// can since have gone private.
45func (s *Store) ListBookmarks(userID int64) ([]Repo, error) {
46 rows, err := s.DB.Query(repoSelect+`
47 JOIN repo_bookmarks b ON b.repo_id = r.id
48 WHERE b.user_id = ? ORDER BY b.bookmarked_at DESC`, userID)
49 if err != nil {
50 return nil, err
51 }
52 defer rows.Close()
53 var out []Repo
54 for rows.Next() {
55 r, err := scanRepo(rows)
56 if err != nil {
57 return nil, err
58 }
59 out = append(out, r)
60 }
61 return out, rows.Err()
62}
internal/store/migrations/0043_bookmarks.down.sql added +1
@@ -0,0 +1 @@
1DROP TABLE repo_bookmarks;
internal/store/migrations/0043_bookmarks.up.sql added +10
@@ -0,0 +1,10 @@
1-- Bookmarks are a public "saved for later", separate from pins: a pin is
2-- private quick access to your own work, a bookmark says a repository is
3-- worth coming back to and its count is a signal of that.
4CREATE TABLE repo_bookmarks (
5 user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
6 repo_id INTEGER NOT NULL REFERENCES repos(id) ON DELETE CASCADE,
7 bookmarked_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
8 PRIMARY KEY (user_id, repo_id)
9);
10CREATE INDEX repo_bookmarks_by_repo ON repo_bookmarks (repo_id);
internal/web/templates/bookmarks.html added +16
@@ -0,0 +1,16 @@
1{{define "title"}}bookmarks · {{.Site}}{{end}}
2{{define "content"}}
3<h1>Bookmarks</h1>
4<p class="meta">Repositories you saved to come back to. A pin is private quick
5access to your own work; a bookmark says something is worth returning to, and
6its count is public.</p>
7<ul class="repolist">
8{{range .Bookmarks}}<li>
9 <p class="title"><a href="/{{.Path}}">{{.Path}}</a>
10 {{if eq .Visibility "private"}}<span class="chip">Private</span>{{end}}
11 <span class="count">{{.Bookmarks}}</span></p>
12 {{if .Description}}<p class="desc">{{.Description}}</p>{{end}}
13</li>
14{{else}}<li class="empty">nothing bookmarked yet — open a repository and press Bookmark</li>{{end}}
15</ul>
16{{end}}
internal/web/templates/layout.html +2
@@ -22,6 +22,7 @@
22 {{if .Viewer}}<li><a href="/">Dashboard</a></li>{{end}} 22 {{if .Viewer}}<li><a href="/">Dashboard</a></li>{{end}}
23 <li><a {{if eq (str . "Tab") "explore"}}aria-current="page" {{end}}href="/explore">Explore</a></li> 23 <li><a {{if eq (str . "Tab") "explore"}}aria-current="page" {{end}}href="/explore">Explore</a></li>
24 {{if .Viewer}}<li><a {{if eq (str . "Tab") "notifications"}}aria-current="page" {{end}}href="/notifications">Notifications{{with .Rail.Unread}} <b class="count">{{.}}</b>{{end}}</a></li> 24 {{if .Viewer}}<li><a {{if eq (str . "Tab") "notifications"}}aria-current="page" {{end}}href="/notifications">Notifications{{with .Rail.Unread}} <b class="count">{{.}}</b>{{end}}</a></li>
25 <li><a {{if eq (str . "Tab") "bookmarks"}}aria-current="page" {{end}}href="/bookmarks">Bookmarks</a></li>
25 <li><a href="/new">New repository</a></li>{{end}} 26 <li><a href="/new">New repository</a></li>{{end}}
26 </ul> 27 </ul>
27 {{with .Rail.Pinned}} 28 {{with .Rail.Pinned}}
@@ -65,6 +66,7 @@
65 <span class="grow"></span> 66 <span class="grow"></span>
66 {{if $.Viewer}}<form method="post" action="/{{.OwnerName}}/{{.Name}}/pin" class="inline"><button type="submit" class="btn" aria-pressed="{{if field $ "Pinned"}}true{{else}}false{{end}}"><span aria-hidden="true">{{if field $ "Pinned"}}★{{else}}☆{{end}}</span> {{if field $ "Pinned"}}Pinned{{else}}Pin{{end}}</button></form> 67 {{if $.Viewer}}<form method="post" action="/{{.OwnerName}}/{{.Name}}/pin" class="inline"><button type="submit" class="btn" aria-pressed="{{if field $ "Pinned"}}true{{else}}false{{end}}"><span aria-hidden="true">{{if field $ "Pinned"}}★{{else}}☆{{end}}</span> {{if field $ "Pinned"}}Pinned{{else}}Pin{{end}}</button></form>
67 <form method="post" action="/{{.OwnerName}}/{{.Name}}/watch" class="inline"><button type="submit" class="btn" aria-pressed="{{if eq (str $ "Watch") "watching"}}true{{else}}false{{end}}">{{if eq (str $ "Watch") "watching"}}Watching{{else}}Watch{{end}}</button></form> 68 <form method="post" action="/{{.OwnerName}}/{{.Name}}/watch" class="inline"><button type="submit" class="btn" aria-pressed="{{if eq (str $ "Watch") "watching"}}true{{else}}false{{end}}">{{if eq (str $ "Watch") "watching"}}Watching{{else}}Watch{{end}}</button></form>
69 <form method="post" action="/{{.OwnerName}}/{{.Name}}/bookmark" class="inline"><button type="submit" class="btn" aria-pressed="{{if field $ "Marked"}}true{{else}}false{{end}}">{{if field $ "Marked"}}Bookmarked{{else}}Bookmark{{end}}</button></form>
68 <form method="post" action="/{{.OwnerName}}/{{.Name}}/fork" class="inline"><button type="submit" class="btn">Fork</button></form>{{end}} 70 <form method="post" action="/{{.OwnerName}}/{{.Name}}/fork" class="inline"><button type="submit" class="btn">Fork</button></form>{{end}}
69 </div> 71 </div>
70 {{$top := topTab (str $ "Tab")}} 72 {{$top := topTab (str $ "Tab")}}
internal/web/templates/tree.html +1
@@ -16,6 +16,7 @@
16 <a href="{{$r}}/log/{{.Ref}}"><strong>{{.Facts.Commits}}</strong> commit{{if ne .Facts.Commits 1}}s{{end}}</a> 16 <a href="{{$r}}/log/{{.Ref}}"><strong>{{.Facts.Commits}}</strong> commit{{if ne .Facts.Commits 1}}s{{end}}</a>
17 <a href="{{$r}}/refs"><strong>{{.Facts.Branches}}</strong> branch{{if ne .Facts.Branches 1}}es{{end}}</a> 17 <a href="{{$r}}/refs"><strong>{{.Facts.Branches}}</strong> branch{{if ne .Facts.Branches 1}}es{{end}}</a>
18 <a href="{{$r}}/refs"><strong>{{.Facts.Tags}}</strong> tag{{if ne .Facts.Tags 1}}s{{end}}</a> 18 <a href="{{$r}}/refs"><strong>{{.Facts.Tags}}</strong> tag{{if ne .Facts.Tags 1}}s{{end}}</a>
19 {{if .Facts.Bookmarks}}<span><strong>{{.Facts.Bookmarks}}</strong> bookmark{{if ne .Facts.Bookmarks 1}}s{{end}}</span>{{end}}
19 {{with .Facts.License}}<span class="fact">{{.}}</span>{{end}} 20 {{with .Facts.License}}<span class="fact">{{.}}</span>{{end}}
20 {{with .Facts.Release}}<a href="{{$r}}/releases">latest <strong>{{.}}</strong></a>{{end}} 21 {{with .Facts.Release}}<a href="{{$r}}/releases">latest <strong>{{.}}</strong></a>{{end}}
21 {{with .Facts.Build}}<a href="{{$r}}/builds">build <span class="badge badge-{{.}}">{{.}}</span></a>{{end}} 22 {{with .Facts.Build}}<a href="{{$r}}/builds">build <span class="badge badge-{{.}}">{{.}}</span></a>{{end}}