Commit b41ccc6005
Verified · cmc
Layout: unified · split
cmd/gitbay/main.go +2
| @@ -218,6 +218,8 @@ func repoCmd() *cobra.Command { | |||
| 218 | pass("fork", "fork a repository under your account", passOpts{server: []string{"repo", "fork"}, needsRepo: true}), | 218 | pass("fork", "fork a repository under your account", passOpts{server: []string{"repo", "fork"}, needsRepo: true}), |
| 219 | pass("search", "find repositories by name, description, or topic: <query>", passOpts{server: []string{"repo", "search"}}), | 219 | pass("search", "find repositories by name, description, or topic: <query>", passOpts{server: []string{"repo", "search"}}), |
| 220 | pass("grep", "search file contents: <query> [--ref <ref>]", passOpts{server: []string{"repo", "grep"}, needsRepo: true}), | 220 | pass("grep", "search file contents: <query> [--ref <ref>]", passOpts{server: []string{"repo", "grep"}, needsRepo: true}), |
| 221 | pass("pin", "pin a repository to your dashboard", passOpts{server: []string{"repo", "pin"}, needsRepo: true}), | ||
| 222 | pass("unpin", "unpin a repository", passOpts{server: []string{"repo", "unpin"}, needsRepo: true}), | ||
| 221 | pass("archive", "archive a repository (read-only)", passOpts{server: []string{"repo", "archive"}, needsRepo: true}), | 223 | pass("archive", "archive a repository (read-only)", passOpts{server: []string{"repo", "archive"}, needsRepo: true}), |
| 222 | pass("unarchive", "unarchive a repository", passOpts{server: []string{"repo", "unarchive"}, needsRepo: true}), | 224 | pass("unarchive", "unarchive a repository", passOpts{server: []string{"repo", "unarchive"}, needsRepo: true}), |
| 223 | local("clone", "clone via ssh: gitbay repo clone <owner/name> [dir]", cmdRepoClone), | 225 | local("clone", "clone via ssh: gitbay repo clone <owner/name> [dir]", cmdRepoClone), |
docs/roadmap.org +2
| @@ -54,6 +54,8 @@ step. | |||
| 54 | - [[https://gitbay.org/krz/gitbay/issues/7][#7]] search — done 2026-08-24 (repo search by name/desc/topic, | 54 | - [[https://gitbay.org/krz/gitbay/issues/7][#7]] search — done 2026-08-24 (repo search by name/desc/topic, |
| 55 | per-repo git grep on web+SSH; cross-repo indexer only if ever needed) | 55 | per-repo git grep on web+SSH; cross-repo indexer only if ever needed) |
| 56 | - [[https://gitbay.org/krz/gitbay/issues/20][#20]] milestones and issue templates | 56 | - [[https://gitbay.org/krz/gitbay/issues/20][#20]] milestones and issue templates |
| 57 | - [[https://gitbay.org/krz/gitbay/issues/30][#30]] activity graph on owner pages (platform-recorded activity | ||
| 58 | signal; events table already covers issues/MRs, extend to commits) | ||
| 57 | 59 | ||
| 58 | * Phase 3 — other people's forges | 60 | * Phase 3 — other people's forges |
| 59 | 61 | ||
docs/users.org +2
| @@ -98,6 +98,8 @@ gitbay repo settings git-daemon you/project on # expose over git:// | |||
| 98 | gitbay repo topics add you/project cli forge # free-form tags, shown on the web | 98 | gitbay repo topics add you/project cli forge # free-form tags, shown on the web |
| 99 | gitbay repo search forge # find repos by name/description/topic | 99 | gitbay repo search forge # find repos by name/description/topic |
| 100 | gitbay repo grep you/project "some string" # literal git grep over the default branch | 100 | gitbay repo grep you/project "some string" # literal git grep over the default branch |
| 101 | gitbay repo pin you/project # pin to your web dashboard | ||
| 102 | gitbay repo unpin you/project | ||
| 101 | gitbay repo archive you/project # read-only: pushes and issue/MR | 103 | gitbay repo archive you/project # read-only: pushes and issue/MR |
| 102 | gitbay repo unarchive you/project # writes refused, browsing intact | 104 | gitbay repo unarchive you/project # writes refused, browsing intact |
| 103 | #+end_src | 105 | #+end_src |
e2e/dashboard_test.go added +112
| @@ -0,0 +1,112 @@ | |||
| 1 | package e2e | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "encoding/json" | ||
| 5 | "os" | ||
| 6 | "path/filepath" | ||
| 7 | "strings" | ||
| 8 | "testing" | ||
| 9 | ) | ||
| 10 | |||
| 11 | func TestDashboard(t *testing.T) { | ||
| 12 | inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n") | ||
| 13 | aliceKey := inst.newKey(t, "alice") | ||
| 14 | bobKey := inst.newKey(t, "bob") | ||
| 15 | inst.admin(t, "admin", "user", "create", "alice", | ||
| 16 | "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified") | ||
| 17 | inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub") | ||
| 18 | |||
| 19 | // Alice's repo with an open issue and an open MR authored by bob. | ||
| 20 | if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 { | ||
| 21 | t.Fatalf("repo create: %s", errOut) | ||
| 22 | } | ||
| 23 | if _, _, code := inst.ssh(t, aliceKey, "", "repo", "access", "grant", "alice/app", "bob", "write"); code != 0 { | ||
| 24 | t.Fatal("grant failed") | ||
| 25 | } | ||
| 26 | work := t.TempDir() | ||
| 27 | env := inst.gitEnv(aliceKey) | ||
| 28 | mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w") | ||
| 29 | dir := filepath.Join(work, "w") | ||
| 30 | os.WriteFile(filepath.Join(dir, "a.txt"), []byte("a\n"), 0o644) | ||
| 31 | mustGit(t, dir, env, "checkout", "-q", "-b", "main") | ||
| 32 | mustGit(t, dir, env, "add", ".") | ||
| 33 | mustGit(t, dir, env, "commit", "-q", "-m", "base") | ||
| 34 | mustGit(t, dir, env, "push", "-q", "origin", "main") | ||
| 35 | mustGit(t, dir, env, "checkout", "-q", "-b", "feat") | ||
| 36 | os.WriteFile(filepath.Join(dir, "a.txt"), []byte("a\nb\n"), 0o644) | ||
| 37 | mustGit(t, dir, env, "add", ".") | ||
| 38 | mustGit(t, dir, env, "commit", "-q", "-m", "feat") | ||
| 39 | mustGit(t, dir, env, "push", "-q", "origin", "feat") | ||
| 40 | if _, _, code := inst.ssh(t, bobKey, "", "mr", "create", "alice/app", | ||
| 41 | "--source", "feat", "--target", "main", "--title", "'from bob'"); code != 0 { | ||
| 42 | t.Fatal("mr create failed") | ||
| 43 | } | ||
| 44 | if _, _, code := inst.ssh(t, aliceKey, "", "issue", "create", "alice/app", "--title", "'todo one'"); code != 0 { | ||
| 45 | t.Fatal("issue create failed") | ||
| 46 | } | ||
| 47 | |||
| 48 | // Pins: read access required; unpinning something never pinned 404s. | ||
| 49 | if _, _, code := inst.ssh(t, aliceKey, "", "repo", "pin", "alice/app"); code != 0 { | ||
| 50 | t.Fatal("pin failed") | ||
| 51 | } | ||
| 52 | if _, _, code := inst.ssh(t, aliceKey, "", "repo", "unpin", "alice/app"); code != 0 { | ||
| 53 | t.Fatal("unpin failed") | ||
| 54 | } | ||
| 55 | if _, _, code := inst.ssh(t, aliceKey, "", "repo", "unpin", "alice/app"); code != 3 { | ||
| 56 | t.Fatal("double unpin should be not-found") | ||
| 57 | } | ||
| 58 | if _, _, code := inst.ssh(t, aliceKey, "", "repo", "pin", "alice/app"); code != 0 { | ||
| 59 | t.Fatal("re-pin failed") | ||
| 60 | } | ||
| 61 | |||
| 62 | // Anonymous homepage: landing with explore link, repos on /explore. | ||
| 63 | status, body := inst.get(t, "/") | ||
| 64 | if status != 200 || !strings.Contains(body, `href="/explore"`) || !strings.Contains(body, "CLI-first") { | ||
| 65 | t.Fatalf("landing: %d", status) | ||
| 66 | } | ||
| 67 | if strings.Contains(body, "alice/app") { | ||
| 68 | t.Fatal("landing lists repositories") | ||
| 69 | } | ||
| 70 | _, body = inst.get(t, "/explore") | ||
| 71 | if !strings.Contains(body, "alice/app") { | ||
| 72 | t.Fatal("explore missing public repo") | ||
| 73 | } | ||
| 74 | |||
| 75 | // Logged-in homepage: dashboard with pinned repo and open items. | ||
| 76 | out, errOut, code := inst.ssh(t, aliceKey, "", "web", "login", "--json") | ||
| 77 | if code != 0 { | ||
| 78 | t.Fatalf("web login: %s", errOut) | ||
| 79 | } | ||
| 80 | var env2 struct { | ||
| 81 | Data struct { | ||
| 82 | URL string `json:"url"` | ||
| 83 | } `json:"data"` | ||
| 84 | } | ||
| 85 | json.Unmarshal([]byte(out), &env2) | ||
| 86 | loginPath := env2.Data.URL[strings.Index(env2.Data.URL, "/login"):] | ||
| 87 | browser := newBrowser(t) | ||
| 88 | if status, _ := browserGet(t, browser, inst.base()+loginPath); status != 200 { | ||
| 89 | t.Fatalf("login: %d", status) | ||
| 90 | } | ||
| 91 | status, body = browserGet(t, browser, inst.base()+"/") | ||
| 92 | if status != 200 || !strings.Contains(body, "logged in as alice") { | ||
| 93 | t.Fatalf("dashboard: %d", status) | ||
| 94 | } | ||
| 95 | for _, want := range []string{ | ||
| 96 | ">pinned</h2>", ">app<", // pinned card | ||
| 97 | "alice/app!1 from bob", "alice/app#1 todo one", | ||
| 98 | `href="/alice/app/mrs/1"`, `href="/alice/app/issues/1"`, | ||
| 99 | } { | ||
| 100 | if !strings.Contains(body, want) { | ||
| 101 | t.Errorf("dashboard missing %q", want) | ||
| 102 | } | ||
| 103 | } | ||
| 104 | |||
| 105 | // The MR diff is collapsed by default in a <details> with stats. | ||
| 106 | _, body = inst.get(t, "/alice/app/mrs/1") | ||
| 107 | if !strings.Contains(body, `<details class="difffold">`) || | ||
| 108 | strings.Contains(body, `<details class="difffold" open`) || | ||
| 109 | !strings.Contains(body, "1 file changed") { | ||
| 110 | t.Fatal("diff not collapsed with stats") | ||
| 111 | } | ||
| 112 | } | ||
e2e/description_test.go +2 −2
| @@ -47,8 +47,8 @@ func TestRepoDescriptions(t *testing.T) { | |||
| 47 | t.Fatalf("non-admin set: exit %d, want 4", code) | 47 | t.Fatalf("non-admin set: exit %d, want 4", code) |
| 48 | } | 48 | } |
| 49 | 49 | ||
| 50 | // Web: index, owner page, and repo header all show it. | 50 | // Web: explore, owner page, and repo header all show it. |
| 51 | for _, path := range []string{"/", "/alice", "/alice/tool"} { | 51 | for _, path := range []string{"/explore", "/alice", "/alice/tool"} { |
| 52 | status, body := inst.get(t, path) | 52 | status, body := inst.get(t, path) |
| 53 | if status != 200 || !strings.Contains(body, "better now") { | 53 | if status != 200 || !strings.Contains(body, "better now") { |
| 54 | t.Fatalf("description missing at %s (%d)", path, status) | 54 | t.Fatalf("description missing at %s (%d)", path, status) |
e2e/org_test.go +2 −2
| @@ -171,9 +171,9 @@ func TestOrganizations(t *testing.T) { | |||
| 171 | if _, _, code = inst.ssh(t, aliceKey, "", "repo", "create", "puborg/site"); code != 0 { | 171 | if _, _, code = inst.ssh(t, aliceKey, "", "repo", "create", "puborg/site"); code != 0 { |
| 172 | t.Fatal("org public repo failed") | 172 | t.Fatal("org public repo failed") |
| 173 | } | 173 | } |
| 174 | status, body := inst.get(t, "/") | 174 | status, body := inst.get(t, "/explore") |
| 175 | if status != 200 || !strings.Contains(body, "puborg/site") { | 175 | if status != 200 || !strings.Contains(body, "puborg/site") { |
| 176 | t.Fatalf("org repo missing from index: %d", status) | 176 | t.Fatalf("org repo missing from explore: %d", status) |
| 177 | } | 177 | } |
| 178 | if status, _ := inst.get(t, "/puborg/site"); status != 200 { | 178 | if status, _ := inst.get(t, "/puborg/site"); status != 200 { |
| 179 | t.Fatalf("org repo page: %d", status) | 179 | t.Fatalf("org repo page: %d", status) |
e2e/search_test.go +7 −7
| @@ -69,18 +69,18 @@ func TestSearch(t *testing.T) { | |||
| 69 | t.Fatalf("private grep parity: exit %d, %s", code, errOut) | 69 | t.Fatalf("private grep parity: exit %d, %s", code, errOut) |
| 70 | } | 70 | } |
| 71 | 71 | ||
| 72 | // Web: index filter respects the query and visibility. | 72 | // Web: explore filter respects the query and visibility. |
| 73 | status, body := inst.get(t, "/?q=webapp") | 73 | status, body := inst.get(t, "/explore?q=webapp") |
| 74 | if status != 200 || !strings.Contains(body, "alice/webapp") { | 74 | if status != 200 || !strings.Contains(body, "alice/webapp") { |
| 75 | t.Fatalf("index filter: %d", status) | 75 | t.Fatalf("explore filter: %d", status) |
| 76 | } | 76 | } |
| 77 | _, body = inst.get(t, "/?q=nomatchhere") | 77 | _, body = inst.get(t, "/explore?q=nomatchhere") |
| 78 | if strings.Contains(body, "alice/webapp") { | 78 | if strings.Contains(body, "alice/webapp") { |
| 79 | t.Fatal("index filter did not filter") | 79 | t.Fatal("explore filter did not filter") |
| 80 | } | 80 | } |
| 81 | _, body = inst.get(t, "/?q=hidden") | 81 | _, body = inst.get(t, "/explore?q=hidden") |
| 82 | if strings.Contains(body, "secret") { | 82 | if strings.Contains(body, "secret") { |
| 83 | t.Fatal("private repo leaked on index search") | 83 | t.Fatal("private repo leaked on explore search") |
| 84 | } | 84 | } |
| 85 | 85 | ||
| 86 | // Web: repo code search with mark and blob line anchor. | 86 | // Web: repo code search with mark and blob line anchor. |
e2e/web_test.go +8 −3
| @@ -66,13 +66,18 @@ func TestWebUI(t *testing.T) { | |||
| 66 | t.Fatal("create private failed") | 66 | t.Fatal("create private failed") |
| 67 | } | 67 | } |
| 68 | 68 | ||
| 69 | // Index lists the public repo, not the private one. | 69 | // Explore lists the public repo, not the private one; the anonymous |
| 70 | // homepage is a landing page pointing there. | ||
| 70 | status, body := inst.get(t, "/") | 71 | status, body := inst.get(t, "/") |
| 72 | if status != 200 || !strings.Contains(body, `href="/explore"`) { | ||
| 73 | t.Fatalf("landing: %d\n%s", status, body) | ||
| 74 | } | ||
| 75 | status, body = inst.get(t, "/explore") | ||
| 71 | if status != 200 || !strings.Contains(body, "alice/site") { | 76 | if status != 200 || !strings.Contains(body, "alice/site") { |
| 72 | t.Fatalf("index: %d\n%s", status, body) | 77 | t.Fatalf("explore: %d\n%s", status, body) |
| 73 | } | 78 | } |
| 74 | if strings.Contains(body, "secret") { | 79 | if strings.Contains(body, "secret") { |
| 75 | t.Fatal("index leaks private repo") | 80 | t.Fatal("explore leaks private repo") |
| 76 | } | 81 | } |
| 77 | 82 | ||
| 78 | // Repo home: tree entries plus rendered README. | 83 | // Repo home: tree entries plus rendered README. |
internal/control/repo.go +34
| @@ -64,6 +64,10 @@ func init() { | |||
| 64 | Summary: "find repositories by name, description, or topic: repo search <query>", ReadOnly: true, Run: runRepoSearch}) | 64 | Summary: "find repositories by name, description, or topic: repo search <query>", ReadOnly: true, Run: runRepoSearch}) |
| 65 | register(Command{Path: []string{"repo", "grep"}, | 65 | register(Command{Path: []string{"repo", "grep"}, |
| 66 | Summary: "search file contents: repo grep <owner/name> <query> [--ref <ref>]", ReadOnly: true, Run: runRepoGrep}) | 66 | Summary: "search file contents: repo grep <owner/name> <query> [--ref <ref>]", ReadOnly: true, Run: runRepoGrep}) |
| 67 | register(Command{Path: []string{"repo", "pin"}, | ||
| 68 | Summary: "pin a repository to your dashboard: repo pin <owner/name>", Run: runRepoPin}) | ||
| 69 | register(Command{Path: []string{"repo", "unpin"}, | ||
| 70 | Summary: "unpin a repository: repo unpin <owner/name>", Run: runRepoUnpin}) | ||
| 67 | } | 71 | } |
| 68 | 72 | ||
| 69 | const ( | 73 | const ( |
| @@ -690,6 +694,36 @@ func runRepoGrep(c *Ctx, args []string) int { | |||
| 690 | }) | 694 | }) |
| 691 | } | 695 | } |
| 692 | 696 | ||
| 697 | func runRepoPin(c *Ctx, args []string) int { return setPinned(c, args, true) } | ||
| 698 | func runRepoUnpin(c *Ctx, args []string) int { return setPinned(c, args, false) } | ||
| 699 | |||
| 700 | func setPinned(c *Ctx, args []string, pin bool) int { | ||
| 701 | verb := "pin" | ||
| 702 | if !pin { | ||
| 703 | verb = "unpin" | ||
| 704 | } | ||
| 705 | if len(args) != 1 { | ||
| 706 | return c.fail(protocol.ExitUsage, "usage: repo %s <owner/name>", verb) | ||
| 707 | } | ||
| 708 | repo, code := resolveRepo(c, args[0], policy.CanRead) | ||
| 709 | if code >= 0 { | ||
| 710 | return code | ||
| 711 | } | ||
| 712 | if pin { | ||
| 713 | if err := c.Store.PinRepo(c.User.ID, repo.ID); err != nil { | ||
| 714 | return c.fail(protocol.ExitFailure, "%v", err) | ||
| 715 | } | ||
| 716 | } else if err := c.Store.UnpinRepo(c.User.ID, repo.ID); err != nil { | ||
| 717 | if errors.Is(err, store.ErrNotFound) { | ||
| 718 | return c.fail(protocol.ExitNotFound, "%s is not pinned", repo.Path()) | ||
| 719 | } | ||
| 720 | return c.fail(protocol.ExitFailure, "%v", err) | ||
| 721 | } | ||
| 722 | return c.emit(map[string]string{verb + "ned": repo.Path()}, func(w io.Writer) { | ||
| 723 | fmt.Fprintf(w, "%sned %s\n", verb, repo.Path()) | ||
| 724 | }) | ||
| 725 | } | ||
| 726 | |||
| 693 | func runProtect(c *Ctx, args []string) int { return setProtect(c, args, true) } | 727 | func runProtect(c *Ctx, args []string) int { return setProtect(c, args, true) } |
| 694 | func runUnprotect(c *Ctx, args []string) int { return setProtect(c, args, false) } | 728 | func runUnprotect(c *Ctx, args []string) int { return setProtect(c, args, false) } |
| 695 | 729 | ||
internal/httpd/routes.go +1
| @@ -27,6 +27,7 @@ func (s *Server) Routes() []Route { | |||
| 27 | // Web UI, read-only. These exist in every mode. | 27 | // Web UI, read-only. These exist in every mode. |
| 28 | routes = append(routes, | 28 | routes = append(routes, |
| 29 | Route{Method: "GET", Pattern: "/{$}", Handler: s.index}, | 29 | Route{Method: "GET", Pattern: "/{$}", Handler: s.index}, |
| 30 | Route{Method: "GET", Pattern: "/explore", Handler: s.explore}, | ||
| 30 | Route{Method: "GET", Pattern: "/static/style.css", Handler: s.stylesheet}, | 31 | Route{Method: "GET", Pattern: "/static/style.css", Handler: s.stylesheet}, |
| 31 | Route{Method: "GET", Pattern: "/favicon.svg", Handler: s.favicon}, | 32 | Route{Method: "GET", Pattern: "/favicon.svg", Handler: s.favicon}, |
| 32 | Route{Method: "GET", Pattern: "/{owner}", Handler: s.ownerPage}, | 33 | Route{Method: "GET", Pattern: "/{owner}", Handler: s.ownerPage}, |
internal/store/dashboard.go added +108
| @@ -0,0 +1,108 @@ | |||
| 1 | package store | ||
| 2 | |||
| 3 | // DashboardItem is one open issue or MR row on the logged-in homepage. | ||
| 4 | type DashboardItem struct { | ||
| 5 | RepoPath string | ||
| 6 | Number int64 | ||
| 7 | Title string | ||
| 8 | Author string | ||
| 9 | State string | ||
| 10 | UpdatedAt string | ||
| 11 | } | ||
| 12 | |||
| 13 | // involvedRepos filters to repositories the user owns, is granted on, or | ||
| 14 | // reaches through org membership — or rows the user authored anywhere. | ||
| 15 | const involvedCond = `( | ||
| 16 | x.author_id = ?1 | ||
| 17 | OR (r.owner_kind = 'user' AND r.owner_id = ?1) | ||
| 18 | OR EXISTS (SELECT 1 FROM repo_access a | ||
| 19 | WHERE a.repo_id = r.id AND a.subject_kind = 'user' AND a.subject_id = ?1) | ||
| 20 | OR EXISTS (SELECT 1 FROM org_members mm | ||
| 21 | WHERE r.owner_kind = 'org' AND mm.org_id = r.owner_id AND mm.user_id = ?1) | ||
| 22 | )` | ||
| 23 | |||
| 24 | func (s *Store) dashboardQuery(q string, userID int64) ([]DashboardItem, error) { | ||
| 25 | rows, err := s.DB.Query(q, userID) | ||
| 26 | if err != nil { | ||
| 27 | return nil, err | ||
| 28 | } | ||
| 29 | defer rows.Close() | ||
| 30 | var out []DashboardItem | ||
| 31 | for rows.Next() { | ||
| 32 | var d DashboardItem | ||
| 33 | if err := rows.Scan(&d.RepoPath, &d.Number, &d.Title, &d.Author, &d.State, &d.UpdatedAt); err != nil { | ||
| 34 | return nil, err | ||
| 35 | } | ||
| 36 | out = append(out, d) | ||
| 37 | } | ||
| 38 | return out, rows.Err() | ||
| 39 | } | ||
| 40 | |||
| 41 | // DashboardMRs returns open merge requests involving the user: on their | ||
| 42 | // repositories (owned, granted, org) or authored by them anywhere. | ||
| 43 | func (s *Store) DashboardMRs(userID int64) ([]DashboardItem, error) { | ||
| 44 | return s.dashboardQuery(` | ||
| 45 | SELECT COALESCE(u.username, o.name) || '/' || r.name, | ||
| 46 | x.number, x.title, au.username, x.state, x.updated_at | ||
| 47 | FROM merge_requests x | ||
| 48 | JOIN repos r ON r.id = x.repo_id | ||
| 49 | LEFT JOIN users u ON r.owner_kind = 'user' AND u.id = r.owner_id | ||
| 50 | LEFT JOIN orgs o ON r.owner_kind = 'org' AND o.id = r.owner_id | ||
| 51 | JOIN users au ON au.id = x.author_id | ||
| 52 | WHERE x.state IN ('open', 'source_gone') AND `+involvedCond+` | ||
| 53 | ORDER BY x.updated_at DESC LIMIT 50`, userID) | ||
| 54 | } | ||
| 55 | |||
| 56 | // DashboardIssues is the issue counterpart of DashboardMRs. | ||
| 57 | func (s *Store) DashboardIssues(userID int64) ([]DashboardItem, error) { | ||
| 58 | return s.dashboardQuery(` | ||
| 59 | SELECT COALESCE(u.username, o.name) || '/' || r.name, | ||
| 60 | x.number, x.title, au.username, x.state, x.updated_at | ||
| 61 | FROM issues x | ||
| 62 | JOIN repos r ON r.id = x.repo_id | ||
| 63 | LEFT JOIN users u ON r.owner_kind = 'user' AND u.id = r.owner_id | ||
| 64 | LEFT JOIN orgs o ON r.owner_kind = 'org' AND o.id = r.owner_id | ||
| 65 | JOIN users au ON au.id = x.author_id | ||
| 66 | WHERE x.state = 'open' AND `+involvedCond+` | ||
| 67 | ORDER BY x.updated_at DESC LIMIT 50`, userID) | ||
| 68 | } | ||
| 69 | |||
| 70 | func (s *Store) PinRepo(userID, repoID int64) error { | ||
| 71 | _, err := s.DB.Exec( | ||
| 72 | "INSERT INTO repo_pins (user_id, repo_id) VALUES (?, ?) ON CONFLICT DO NOTHING", | ||
| 73 | userID, repoID) | ||
| 74 | return err | ||
| 75 | } | ||
| 76 | |||
| 77 | func (s *Store) UnpinRepo(userID, repoID int64) error { | ||
| 78 | res, err := s.DB.Exec( | ||
| 79 | "DELETE FROM repo_pins WHERE user_id = ? AND repo_id = ?", userID, repoID) | ||
| 80 | if err != nil { | ||
| 81 | return err | ||
| 82 | } | ||
| 83 | if n, _ := res.RowsAffected(); n == 0 { | ||
| 84 | return ErrNotFound | ||
| 85 | } | ||
| 86 | return nil | ||
| 87 | } | ||
| 88 | |||
| 89 | // PinnedRepos returns the user's pinned repositories in pin order. The | ||
| 90 | // caller applies visibility checks before rendering. | ||
| 91 | func (s *Store) PinnedRepos(userID int64) ([]Repo, error) { | ||
| 92 | rows, err := s.DB.Query(repoSelect+` | ||
| 93 | JOIN repo_pins p ON p.repo_id = r.id AND p.user_id = ? | ||
| 94 | ORDER BY p.pinned_at`, userID) | ||
| 95 | if err != nil { | ||
| 96 | return nil, err | ||
| 97 | } | ||
| 98 | defer rows.Close() | ||
| 99 | var out []Repo | ||
| 100 | for rows.Next() { | ||
| 101 | r, err := scanRepo(rows) | ||
| 102 | if err != nil { | ||
| 103 | return nil, err | ||
| 104 | } | ||
| 105 | out = append(out, r) | ||
| 106 | } | ||
| 107 | return out, rows.Err() | ||
| 108 | } | ||
internal/store/migrations/0012_pins.down.sql added +1
| @@ -0,0 +1 @@ | |||
| 1 | DROP TABLE repo_pins; | ||
internal/store/migrations/0012_pins.up.sql added +6
| @@ -0,0 +1,6 @@ | |||
| 1 | CREATE TABLE repo_pins ( | ||
| 2 | user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, | ||
| 3 | repo_id INTEGER NOT NULL REFERENCES repos(id) ON DELETE CASCADE, | ||
| 4 | pinned_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), | ||
| 5 | PRIMARY KEY (user_id, repo_id) | ||
| 6 | ); | ||
internal/web/templates/dashboard.html added +38
| @@ -0,0 +1,38 @@ | |||
| 1 | {{define "title"}}dashboard · {{.Site}}{{end}} | ||
| 2 | {{define "content"}} | ||
| 3 | <div class="headrow"> | ||
| 4 | <h1>dashboard</h1> | ||
| 5 | <span class="spacer"></span> | ||
| 6 | <p class="toolbar">logged in as {{.Viewer}} · <a href="/{{.Viewer}}">your repositories</a> · <a href="/new">new repository</a> · <a href="/explore">explore</a> · | ||
| 7 | <form method="post" action="/logout" class="inline"><button type="submit" class="linklike">logout</button></form></p> | ||
| 8 | </div> | ||
| 9 | {{if .Pinned}}<h2>pinned</h2> | ||
| 10 | <div class="repogrid"> | ||
| 11 | {{range .Pinned}}<div class="repocard"> | ||
| 12 | <p class="reponame"><a href="/{{.OwnerName}}">{{.OwnerName}}</a><span class="sep">/</span><a href="/{{.OwnerName}}/{{.Name}}">{{.Name}}</a>{{if eq .Visibility "private"}} <span class="chip chip-neutral">private</span>{{end}}{{if .Settings.Archived}} <span class="chip chip-stale">archived</span>{{end}}</p> | ||
| 13 | {{if .Desc}}<p class="desc">{{.Desc}}</p>{{end}} | ||
| 14 | <p class="meta">{{template "branchicon"}} {{.DefaultBranch}}</p> | ||
| 15 | </div> | ||
| 16 | {{end}} | ||
| 17 | </div>{{end}} | ||
| 18 | <h2>open merge requests <span class="count">{{len .MRs}}</span></h2> | ||
| 19 | <ul class="issuelist"> | ||
| 20 | {{range .MRs}}<li> | ||
| 21 | <div class="issuemain"> | ||
| 22 | <p class="title"><a href="/{{.RepoPath}}/mrs/{{.Number}}">{{.RepoPath}}!{{.Number}} {{.Title}}</a></p> | ||
| 23 | <p class="meta">{{.Author}} · {{when .UpdatedAt}}{{if eq .State "source_gone"}} · <span class="chip chip-source_gone">source gone</span>{{end}}</p> | ||
| 24 | </div> | ||
| 25 | </li> | ||
| 26 | {{else}}<li class="empty">no open merge requests</li>{{end}} | ||
| 27 | </ul> | ||
| 28 | <h2>open issues <span class="count">{{len .Issues}}</span></h2> | ||
| 29 | <ul class="issuelist"> | ||
| 30 | {{range .Issues}}<li> | ||
| 31 | <div class="issuemain"> | ||
| 32 | <p class="title"><a href="/{{.RepoPath}}/issues/{{.Number}}">{{.RepoPath}}#{{.Number}} {{.Title}}</a></p> | ||
| 33 | <p class="meta">{{.Author}} · {{when .UpdatedAt}}</p> | ||
| 34 | </div> | ||
| 35 | </li> | ||
| 36 | {{else}}<li class="empty">no open issues</li>{{end}} | ||
| 37 | </ul> | ||
| 38 | {{end}} | ||
internal/web/templates/explore.html added +18
| @@ -0,0 +1,18 @@ | |||
| 1 | {{define "title"}}explore · {{.Site}}{{end}} | ||
| 2 | {{define "content"}} | ||
| 3 | <div class="headrow"> | ||
| 4 | <h1>explore</h1> | ||
| 5 | <form method="get" action="/explore" class="searchform compact"> | ||
| 6 | <input type="search" name="q" value="{{.Query}}" placeholder="filter by name, description, topic"> | ||
| 7 | </form> | ||
| 8 | <span class="spacer"></span> | ||
| 9 | </div> | ||
| 10 | <div class="repogrid"> | ||
| 11 | {{range .Repos}}<div class="repocard"> | ||
| 12 | <p class="reponame"><a href="/{{.OwnerName}}">{{.OwnerName}}</a><span class="sep">/</span><a href="/{{.OwnerName}}/{{.Name}}">{{.Name}}</a>{{if .Settings.Archived}} <span class="chip chip-stale">archived</span>{{end}}</p> | ||
| 13 | {{if .Desc}}<p class="desc">{{.Desc}}</p>{{end}} | ||
| 14 | <p class="meta">{{template "branchicon"}} {{.DefaultBranch}}</p> | ||
| 15 | </div> | ||
| 16 | {{else}}<p class="empty">no public repositories yet</p>{{end}} | ||
| 17 | </div> | ||
| 18 | {{end}} | ||
internal/web/templates/index.html deleted −29
| @@ -1,29 +0,0 @@ | |||
| 1 | {{define "title"}}{{.Site}}{{end}} | ||
| 2 | {{define "content"}} | ||
| 3 | <div class="headrow"> | ||
| 4 | <h1>repositories</h1> | ||
| 5 | <form method="get" action="/" class="searchform compact"> | ||
| 6 | <input type="search" name="q" value="{{.Query}}" placeholder="filter by name, description, topic"> | ||
| 7 | </form> | ||
| 8 | <span class="spacer"></span> | ||
| 9 | {{if .Viewer}}<p class="toolbar">logged in as {{.Viewer}} · <a href="/new">new repository</a> · | ||
| 10 | <form method="post" action="/logout" class="inline"><button type="submit" class="linklike">logout</button></form></p>{{end}} | ||
| 11 | </div> | ||
| 12 | <div class="repogrid"> | ||
| 13 | {{range .Repos}}<div class="repocard"> | ||
| 14 | <p class="reponame"><a href="/{{.OwnerName}}">{{.OwnerName}}</a><span class="sep">/</span><a href="/{{.OwnerName}}/{{.Name}}">{{.Name}}</a>{{if .Settings.Archived}} <span class="chip chip-stale">archived</span>{{end}}</p> | ||
| 15 | {{if .Desc}}<p class="desc">{{.Desc}}</p>{{end}} | ||
| 16 | <p class="meta">{{template "branchicon"}} {{.DefaultBranch}}</p> | ||
| 17 | </div> | ||
| 18 | {{else}}<p class="empty">no public repositories yet</p>{{end}} | ||
| 19 | </div> | ||
| 20 | {{if .Mine}}<h2>your private repositories</h2> | ||
| 21 | <div class="repogrid"> | ||
| 22 | {{range .Mine}}<div class="repocard"> | ||
| 23 | <p class="reponame"><a href="/{{.OwnerName}}/{{.Name}}">{{.OwnerName}}/{{.Name}}</a> <span class="chip chip-neutral">{{.Visibility}}</span></p> | ||
| 24 | {{if .Desc}}<p class="desc">{{.Desc}}</p>{{end}} | ||
| 25 | <p class="meta">{{template "branchicon"}} {{.DefaultBranch}}</p> | ||
| 26 | </div> | ||
| 27 | {{end}} | ||
| 28 | </div>{{end}} | ||
| 29 | {{end}} | ||
internal/web/templates/landing.html added +13
| @@ -0,0 +1,13 @@ | |||
| 1 | {{define "title"}}{{.Site}}{{end}} | ||
| 2 | {{define "content"}} | ||
| 3 | <div class="landing"> | ||
| 4 | <h1>{{.Site}}</h1> | ||
| 5 | <p class="lede">A CLI-first git forge. SSH is the API: repositories, issues, and | ||
| 6 | merge requests are managed from your terminal with nothing but OpenSSH — | ||
| 7 | the web is a fast, readable rendering of that state.</p> | ||
| 8 | <pre class="quickstart">ssh git@{{.Host}} help # every command, no client needed | ||
| 9 | git clone ssh://git@{{.Host}}/owner/repo.git</pre> | ||
| 10 | <p><a class="explorelink" href="/explore">explore public repositories →</a></p> | ||
| 11 | {{if .Accounts}}<p class="meta">have an account? mint a browser session from your terminal: <code>gitbay web login</code></p>{{end}} | ||
| 12 | </div> | ||
| 13 | {{end}} | ||