Commit b4a2206ec2
Verified · cmc ci/build: success ci/sonar: success ci/test: success ci/vuln: success
Layout: unified · split
.gitbay/wiki/Parity.org +10
| @@ -139,6 +139,7 @@ column and are always markdown. | ||
| 139 | 139 | | create | yes | yes | yes | |
| 140 | 140 | | fork | yes | yes | no | |
| 141 | 141 | | pin | yes | yes | yes | |
| 142 | | bookmark, bookmark list | yes | yes | no | | |
| 142 | 143 | | watch, mute | yes | yes | no | |
| 143 | 144 | | settings, protection | yes | yes | yes | |
| 144 | 145 | | topics, website | yes | yes | yes | |
| @@ -168,6 +169,15 @@ within a couple of seconds. Cancelling a duplicate of a commit that | ||
| 168 | 169 | already passed the job puts that result back on the commit. The build |
| 169 | 170 | page carries the button while a build is still cancellable. |
| 170 | 171 | |
| 172 | A pin and a bookmark are different things and are stored separately. A | |
| 173 | pin is private quick access to what you are working on, and drives the | |
| 174 | rail; a bookmark is public, says a repository is worth coming back to, | |
| 175 | and its count is the only popularity signal on the instance. Bookmarking | |
| 176 | needs read access only — it is something you do to someone else's | |
| 177 | repository — and a repository bookmarked while public and since made | |
| 178 | private drops out of the listing rather than leaking that it exists | |
| 179 | (krz/gitbay#146). | |
| 180 | ||
| 171 | 181 | Dependency checks are off until a repository's admin turns them on: the |
| 172 | 182 | check tells a public registry what the repository depends on. =repo deps |
| 173 | 183 | status= 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 | 89 | (web edits, merge commits) are always =unsigned= — the server holds no |
| 90 | 90 | signing 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 | |
| 94 | many people have bookmarked it. The web has the control on the | |
| 95 | repository header, the count in the facts bar, and the list at | |
| 96 | =/bookmarks=. Bookmarks are public and counted; pins are private and | |
| 97 | drive the rail. Read access is all a bookmark needs. | |
| 98 | ||
| 92 | 99 | * Profiles |
| 93 | 100 | |
| 94 | 101 | A 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 | 414 | passOpts{server: []string{"repo", "download"}, needsRepo: true}), |
| 415 | 415 | pass("pin", "pin a repository to your dashboard", passOpts{server: []string{"repo", "pin"}, needsRepo: true}), |
| 416 | 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 | 420 | pass("watch", "hear about all activity on a repository", passOpts{server: []string{"repo", "watch"}, needsRepo: true}), |
| 418 | 421 | pass("unwatch", "mute a repository", passOpts{server: []string{"repo", "unwatch"}, needsRepo: true}), |
| 419 | 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 @@ | ||
| 1 | package e2e | |
| 2 | ||
| 3 | import ( | |
| 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). | |
| 14 | func 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. | |
| 91 | func 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 | 143 | "release show": {"alice/app", "v1"}, |
| 144 | 144 | "release asset get": {"alice/app", "v1", "a.txt"}, |
| 145 | 145 | "notifications list": nil, |
| 146 | "repo bookmarks": nil, | |
| 146 | 147 | "search": {"app"}, |
| 147 | 148 | "mr revisions": {"alice/app", "1"}, |
| 148 | 149 | "mr range-diff": {"alice/app", "1"}, |
internal/control/output_test.go +2 −2
| @@ -16,7 +16,7 @@ func TestNamedPayloadsRoundTrip(t *testing.T) { | ||
| 16 | 16 | &Created{}, &MRCreated{}, &IssueShow{}, &MRShow{}, |
| 17 | 17 | &BuildOut{}, &JobOut{}, &ProfileOut{}, &ProfileRepo{}, &ProfileMember{}, |
| 18 | 18 | &DashboardOut{}, &DashboardItem{}, &DashboardBuild{}, &PinnedOut{}, |
| 19 | &FeedOut{}, &ActivityDay{}, &DepsOut{}, &DepBehind{}, &ForkOut{}, | |
| 19 | &FeedOut{}, &ActivityDay{}, &DepsOut{}, &DepBehind{}, &ForkOut{}, &BookmarkOut{}, | |
| 20 | 20 | &SearchResult{}, &ReviewOut{}, &CheckOut{}, &CommitOut{}, |
| 21 | 21 | } |
| 22 | 22 | for _, p := range payloads { |
| @@ -40,7 +40,7 @@ func TestPayloadFieldsAreTagged(t *testing.T) { | ||
| 40 | 40 | types := []any{ |
| 41 | 41 | Created{}, MRCreated{}, BuildOut{}, JobOut{}, ProfileOut{}, ProfileRepo{}, |
| 42 | 42 | ProfileMember{}, DashboardOut{}, DashboardItem{}, DashboardBuild{}, |
| 43 | PinnedOut{}, FeedOut{}, ActivityDay{}, DepsOut{}, DepBehind{}, ForkOut{}, | |
| 43 | PinnedOut{}, FeedOut{}, ActivityDay{}, DepsOut{}, DepBehind{}, ForkOut{}, BookmarkOut{}, | |
| 44 | 44 | SearchResult{}, ReviewOut{}, |
| 45 | 45 | CheckOut{}, CommitOut{}, ServerOut{}, |
| 46 | 46 | } |
internal/control/repo.go +86
| @@ -99,6 +99,15 @@ func init() { | ||
| 99 | 99 | register(Command{Path: []string{"repo", "unpin"}, |
| 100 | 100 | Summary: "unpin a repository", |
| 101 | 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 | |
| 104 | 113 | const ( |
| @@ -881,6 +890,83 @@ func setPinned(c *Ctx, args []string, pin bool) int { | ||
| 881 | 890 | }) |
| 882 | 891 | } |
| 883 | 892 | |
| 893 | func runRepoBookmark(c *Ctx, args []string) int { return setBookmarked(c, args, true) } | |
| 894 | func 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. | |
| 900 | func 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. | |
| 929 | type 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 | ||
| 936 | func 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 | ||
| 884 | 970 | func runProtect(c *Ctx, args []string) int { return setProtect(c, args, true) } |
| 885 | 971 | func 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 | 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. | |
| 227 | func (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. | |
| 243 | func (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 | 253 | // forkSubmit forks the repository under the viewer's account and sends |
| 225 | 254 | // them to it. The command decides everything that matters — read access, |
| 226 | 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 | 19 | License string |
| 20 | 20 | Release string // newest release tag, if any |
| 21 | 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 | |
| 24 | 25 | type factContributor struct { |
| @@ -88,5 +89,6 @@ func (s *Server) factsFor(p repoPage) repoFacts { | ||
| 88 | 89 | if builds, err := s.st.ListBuilds(p.Repo.ID, 1); err == nil && len(builds) > 0 { |
| 89 | 90 | f.Build = builds[0].Status |
| 90 | 91 | } |
| 92 | f.Bookmarks = s.st.BookmarkCount(p.Repo.ID) | |
| 91 | 93 | return f |
| 92 | 94 | } |
internal/httpd/routes.go +3
| @@ -152,6 +152,9 @@ func (s *Server) Routes() []Route { | ||
| 152 | 152 | Handler: s.checkOrigin(s.requireUser(s.releaseSubmit))}, |
| 153 | 153 | Route{Method: "POST", Pattern: "/{owner}/{repo}/labels", Mutating: true, |
| 154 | 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 | 158 | Route{Method: "POST", Pattern: "/{owner}/{repo}/fork", Mutating: true, |
| 156 | 159 | Handler: s.checkOrigin(s.requireUser(s.forkSubmit))}, |
| 157 | 160 | Route{Method: "POST", Pattern: "/{owner}/{repo}/builds", Mutating: true, |
internal/httpd/web.go +4 −1
| @@ -247,6 +247,7 @@ type repoPage struct { | ||
| 247 | 247 | Tab string // active tab in the repo header |
| 248 | 248 | Topics []string |
| 249 | 249 | Pinned bool // by the viewer |
| 250 | Marked bool // bookmarked by the viewer | |
| 250 | 251 | Watch string // the viewer's watch state: watching, muted, or "" |
| 251 | 252 | HasWiki bool |
| 252 | 253 | Host string |
| @@ -307,9 +308,10 @@ func (s *Server) repoFor(w http.ResponseWriter, r *http.Request, ref string) (re | ||
| 307 | 308 | ref = repo.DefaultBranch |
| 308 | 309 | } |
| 309 | 310 | topics, _ := s.st.ListTopics(repo.ID) |
| 310 | pinned, watch := false, "" | |
| 311 | pinned, marked, watch := false, false, "" | |
| 311 | 312 | if viewer.ID != 0 { |
| 312 | 313 | pinned = s.st.IsPinned(viewer.ID, repo.ID) |
| 314 | marked = s.st.IsBookmarked(viewer.ID, repo.ID) | |
| 313 | 315 | watch = s.st.RepoWatchState(repo.ID, viewer.ID) |
| 314 | 316 | } |
| 315 | 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 | 334 | CanAdmin: canAdmin, |
| 333 | 335 | Mirrors: mirrors, |
| 334 | 336 | Pinned: pinned, |
| 337 | Marked: marked, | |
| 335 | 338 | Watch: watch, |
| 336 | 339 | HasWiki: s.hasWiki(repo), |
| 337 | 340 | Host: s.cfg.SiteHost(), |
internal/policy/names.go +1
| @@ -14,6 +14,7 @@ var reservedNames = map[string]bool{ | ||
| 14 | 14 | "admin": true, |
| 15 | 15 | "api": true, |
| 16 | 16 | "archive": true, |
| 17 | "bookmarks": true, | |
| 17 | 18 | "explore": true, |
| 18 | 19 | "favicon.svg": true, |
| 19 | 20 | "gitbay": true, // vanity go-import path on gitbay.org |
internal/store/bookmarks.go added +62
| @@ -0,0 +1,62 @@ | ||
| 1 | package 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 | ||
| 8 | func (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 | ||
| 15 | func (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 | ||
| 27 | func (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. | |
| 36 | func (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. | |
| 45 | func (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 @@ | ||
| 1 | DROP 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. | |
| 4 | CREATE 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 | ); | |
| 10 | CREATE 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 | |
| 5 | access to your own work; a bookmark says something is worth returning to, and | |
| 6 | its 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 | 22 | {{if .Viewer}}<li><a href="/">Dashboard</a></li>{{end}} |
| 23 | 23 | <li><a {{if eq (str . "Tab") "explore"}}aria-current="page" {{end}}href="/explore">Explore</a></li> |
| 24 | 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 | 26 | <li><a href="/new">New repository</a></li>{{end}} |
| 26 | 27 | </ul> |
| 27 | 28 | {{with .Rail.Pinned}} |
| @@ -65,6 +66,7 @@ | ||
| 65 | 66 | <span class="grow"></span> |
| 66 | 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 | 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 | 70 | <form method="post" action="/{{.OwnerName}}/{{.Name}}/fork" class="inline"><button type="submit" class="btn">Fork</button></form>{{end}} |
| 69 | 71 | </div> |
| 70 | 72 | {{$top := topTab (str $ "Tab")}} |
internal/web/templates/tree.html +1
| @@ -16,6 +16,7 @@ | ||
| 16 | 16 | <a href="{{$r}}/log/{{.Ref}}"><strong>{{.Facts.Commits}}</strong> commit{{if ne .Facts.Commits 1}}s{{end}}</a> |
| 17 | 17 | <a href="{{$r}}/refs"><strong>{{.Facts.Branches}}</strong> branch{{if ne .Facts.Branches 1}}es{{end}}</a> |
| 18 | 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 | 20 | {{with .Facts.License}}<span class="fact">{{.}}</span>{{end}} |
| 20 | 21 | {{with .Facts.Release}}<a href="{{$r}}/releases">latest <strong>{{.}}</strong></a>{{end}} |
| 21 | 22 | {{with .Facts.Build}}<a href="{{$r}}/builds">build <span class="badge badge-{{.}}">{{.}}</span></a>{{end}} |