Commit b41ccc6005
Verified · cmc
cmd/gitbay/main.go +2
| @@ -218,6 +218,8 @@ func repoCmd() *cobra.Command { | ||
| 218 | 218 | pass("fork", "fork a repository under your account", passOpts{server: []string{"repo", "fork"}, needsRepo: true}), |
| 219 | 219 | pass("search", "find repositories by name, description, or topic: <query>", passOpts{server: []string{"repo", "search"}}), |
| 220 | 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 | 223 | pass("archive", "archive a repository (read-only)", passOpts{server: []string{"repo", "archive"}, needsRepo: true}), |
| 222 | 224 | pass("unarchive", "unarchive a repository", passOpts{server: []string{"repo", "unarchive"}, needsRepo: true}), |
| 223 | 225 | local("clone", "clone via ssh: gitbay repo clone <owner/name> [dir]", cmdRepoClone), |
docs/roadmap.org +2
| @@ -54,6 +54,8 @@ step. | ||
| 54 | 54 | - [[https://gitbay.org/krz/gitbay/issues/7][#7]] search — done 2026-08-24 (repo search by name/desc/topic, |
| 55 | 55 | per-repo git grep on web+SSH; cross-repo indexer only if ever needed) |
| 56 | 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 | 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 | 98 | gitbay repo topics add you/project cli forge # free-form tags, shown on the web |
| 99 | 99 | gitbay repo search forge # find repos by name/description/topic |
| 100 | 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 | 103 | gitbay repo archive you/project # read-only: pushes and issue/MR |
| 102 | 104 | gitbay repo unarchive you/project # writes refused, browsing intact |
| 103 | 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 | 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. | |
| 51 | for _, path := range []string{"/", "/alice", "/alice/tool"} { | |
| 50 | // Web: explore, owner page, and repo header all show it. | |
| 51 | for _, path := range []string{"/explore", "/alice", "/alice/tool"} { | |
| 52 | 52 | status, body := inst.get(t, path) |
| 53 | 53 | if status != 200 || !strings.Contains(body, "better now") { |
| 54 | 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 | 171 | if _, _, code = inst.ssh(t, aliceKey, "", "repo", "create", "puborg/site"); code != 0 { |
| 172 | 172 | t.Fatal("org public repo failed") |
| 173 | 173 | } |
| 174 | status, body := inst.get(t, "/") | |
| 174 | status, body := inst.get(t, "/explore") | |
| 175 | 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 | 178 | if status, _ := inst.get(t, "/puborg/site"); status != 200 { |
| 179 | 179 | t.Fatalf("org repo page: %d", status) |
e2e/search_test.go +7 −7
| @@ -69,18 +69,18 @@ func TestSearch(t *testing.T) { | ||
| 69 | 69 | t.Fatalf("private grep parity: exit %d, %s", code, errOut) |
| 70 | 70 | } |
| 71 | 71 | |
| 72 | // Web: index filter respects the query and visibility. | |
| 73 | status, body := inst.get(t, "/?q=webapp") | |
| 72 | // Web: explore filter respects the query and visibility. | |
| 73 | status, body := inst.get(t, "/explore?q=webapp") | |
| 74 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 83 | // Repo home: tree entries plus rendered README. |
internal/control/repo.go +34
| @@ -64,6 +64,10 @@ func init() { | ||
| 64 | 64 | Summary: "find repositories by name, description, or topic: repo search <query>", ReadOnly: true, Run: runRepoSearch}) |
| 65 | 65 | register(Command{Path: []string{"repo", "grep"}, |
| 66 | 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 | 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 | 727 | func runProtect(c *Ctx, args []string) int { return setProtect(c, args, true) } |
| 694 | 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 | 27 | // Web UI, read-only. These exist in every mode. |
| 28 | 28 | routes = append(routes, |
| 29 | 29 | Route{Method: "GET", Pattern: "/{$}", Handler: s.index}, |
| 30 | Route{Method: "GET", Pattern: "/explore", Handler: s.explore}, | |
| 30 | 31 | Route{Method: "GET", Pattern: "/static/style.css", Handler: s.stylesheet}, |
| 31 | 32 | Route{Method: "GET", Pattern: "/favicon.svg", Handler: s.favicon}, |
| 32 | 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}} | |