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 | | 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 | |||
| 168 | already passed the job puts that result back on the commit. The build | 169 | already passed the job puts that result back on the commit. The build |
| 169 | page carries the button while a build is still cancellable. | 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 | Dependency checks are off until a repository's admin turns them on: the | 181 | Dependency checks are off until a repository's admin turns them on: the |
| 172 | check tells a public registry what the repository depends on. =repo deps | 182 | check tells a public registry what the repository depends on. =repo deps |
| 173 | status= lists what is behind; the repository's settings page renders the | 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 | (web edits, merge commits) are always =unsigned= — the server holds no | 89 | (web edits, merge commits) are always =unsigned= — the server holds no |
| 90 | signing key on principle. | 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 | * Profiles | 99 | * Profiles |
| 93 | 100 | ||
| 94 | A profile is what =/{owner}= shows: a one-line description, a website, a | 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 | 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 @@ | |||
| 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 | "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 | ||
| 104 | const ( | 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 | func runProtect(c *Ctx, args []string) int { return setProtect(c, args, true) } | 970 | func runProtect(c *Ctx, args []string) int { return setProtect(c, args, true) } |
| 885 | func runUnprotect(c *Ctx, args []string) int { return setProtect(c, args, false) } | 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 | 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. | ||
| 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 | // 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 | ||
| 24 | type factContributor struct { | 25 | type 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 @@ | |||
| 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 | {{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}} |