Commit 32a583340a
Verified · cmc ci/build: success
e2e/accounts_test.go +3 −1
| @@ -87,7 +87,9 @@ func TestWebAccounts(t *testing.T) { | ||
| 87 | 87 | |
| 88 | 88 | browser := newBrowser(t) |
| 89 | 89 | status, body := browserGet(t, browser, inst.base()+loginPath) |
| 90 | if status != 200 || !strings.Contains(body, `logged in as <a href="/alice">alice</a>`) { | |
| 90 | // The rail's footer carries the signed-in account now. | |
| 91 | if status != 200 || !strings.Contains(body, ">Dashboard</h1>") || | |
| 92 | !strings.Contains(body, `class="railuser" href="/alice"`) { | |
| 91 | 93 | t.Fatalf("login redirect landed wrong: %d\n%s", status, body) |
| 92 | 94 | } |
| 93 | 95 | |
e2e/badge_test.go added +34
| @@ -0,0 +1,34 @@ | ||
| 1 | package e2e | |
| 2 | ||
| 3 | import ( | |
| 4 | "strings" | |
| 5 | "testing" | |
| 6 | ) | |
| 7 | ||
| 8 | // TestBuildBadge covers the badge endpoint: it reports the newest build, | |
| 9 | // says "unknown" before any build exists, and 404s for a private repo so | |
| 10 | // it cannot be used to probe for one. | |
| 11 | func TestBuildBadge(t *testing.T) { | |
| 12 | inst := startInstance(t) | |
| 13 | aliceKey := inst.newKey(t, "alice") | |
| 14 | inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub") | |
| 15 | if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 { | |
| 16 | t.Fatalf("repo create: %s", errOut) | |
| 17 | } | |
| 18 | ||
| 19 | status, body := inst.get(t, "/alice/app/badge/build.svg") | |
| 20 | if status != 200 || !strings.Contains(body, "unknown") || !strings.Contains(body, "<svg") { | |
| 21 | t.Fatalf("badge before any build: %d\n%s", status, body) | |
| 22 | } | |
| 23 | ||
| 24 | // Private repositories have no badge, like every other surface. | |
| 25 | if _, _, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/vault", "--private"); code != 0 { | |
| 26 | t.Fatal("private repo create failed") | |
| 27 | } | |
| 28 | if status, _ := inst.get(t, "/alice/vault/badge/build.svg"); status != 404 { | |
| 29 | t.Fatalf("private badge: %d", status) | |
| 30 | } | |
| 31 | if status, _ := inst.get(t, "/alice/nope/badge/build.svg"); status != 404 { | |
| 32 | t.Fatalf("missing repo badge: %d", status) | |
| 33 | } | |
| 34 | } | |
e2e/dashboard_test.go +78 −3
| @@ -89,12 +89,13 @@ func TestDashboard(t *testing.T) { | ||
| 89 | 89 | t.Fatalf("login: %d", status) |
| 90 | 90 | } |
| 91 | 91 | status, body = browserGet(t, browser, inst.base()+"/") |
| 92 | if status != 200 || !strings.Contains(body, `logged in as <a href="/alice">alice</a>`) { | |
| 92 | if status != 200 || !strings.Contains(body, ">Dashboard</h1>") { | |
| 93 | 93 | t.Fatalf("dashboard: %d", status) |
| 94 | 94 | } |
| 95 | 95 | for _, want := range []string{ |
| 96 | ">Pinned</h2>", ">app<", // pinned card | |
| 97 | "alice/app!1 from bob", "alice/app#1 todo one", | |
| 96 | ">Pinned</h2>", ">app<", // pinned group in the aside | |
| 97 | "Waiting on your review", "Assigned to you", "Recent activity", | |
| 98 | "from bob", "alice/app!1", "todo one", "alice/app#1", | |
| 98 | 99 | `href="/alice/app/mrs/1"`, `href="/alice/app/issues/1"`, |
| 99 | 100 | } { |
| 100 | 101 | if !strings.Contains(body, want) { |
| @@ -117,3 +118,77 @@ func TestDashboard(t *testing.T) { | ||
| 117 | 118 | t.Fatalf("diff view missing stat or patch:\n%s", body) |
| 118 | 119 | } |
| 119 | 120 | } |
| 121 | ||
| 122 | // TestDashboardQueues covers the parts of the dashboard that answer "what | |
| 123 | // needs me": the review queue, assigned issues, and the activity feed. | |
| 124 | func TestDashboardQueues(t *testing.T) { | |
| 125 | inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n") | |
| 126 | aliceKey := inst.newKey(t, "alice") | |
| 127 | bobKey := inst.newKey(t, "bob") | |
| 128 | inst.admin(t, "admin", "user", "create", "alice", | |
| 129 | "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified") | |
| 130 | inst.admin(t, "admin", "user", "create", "bob", | |
| 131 | "--key", bobKey+".pub", "--email", "bob@example.test", "--verified") | |
| 132 | ||
| 133 | if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 { | |
| 134 | t.Fatalf("repo create: %s", errOut) | |
| 135 | } | |
| 136 | if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "access", "grant", "alice/app", "bob", "write"); code != 0 { | |
| 137 | t.Fatalf("grant: %s", errOut) | |
| 138 | } | |
| 139 | env := inst.gitEnv(aliceKey) | |
| 140 | work := t.TempDir() | |
| 141 | mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w") | |
| 142 | dir := filepath.Join(work, "w") | |
| 143 | os.WriteFile(filepath.Join(dir, "a.txt"), []byte("a\n"), 0o644) | |
| 144 | mustGit(t, dir, env, "checkout", "-q", "-b", "main") | |
| 145 | mustGit(t, dir, env, "add", ".") | |
| 146 | mustGit(t, dir, env, "commit", "-q", "-m", "base") | |
| 147 | mustGit(t, dir, env, "push", "-q", "origin", "main") | |
| 148 | ||
| 149 | // Bob opens a merge request: it lands in alice's review queue. | |
| 150 | bobEnv := inst.gitEnv(bobKey) | |
| 151 | bobWork := t.TempDir() | |
| 152 | mustGit(t, bobWork, bobEnv, "clone", inst.sshURL("alice/app"), "w") | |
| 153 | bobDir := filepath.Join(bobWork, "w") | |
| 154 | mustGit(t, bobDir, bobEnv, "checkout", "-q", "-b", "fix", "origin/main") | |
| 155 | os.WriteFile(filepath.Join(bobDir, "b.txt"), []byte("b\n"), 0o644) | |
| 156 | mustGit(t, bobDir, bobEnv, "add", ".") | |
| 157 | mustGit(t, bobDir, bobEnv, "commit", "-q", "-m", "the fix") | |
| 158 | mustGit(t, bobDir, bobEnv, "push", "-q", "origin", "fix") | |
| 159 | if _, errOut, code := inst.ssh(t, bobKey, "", "mr", "create", "alice/app", | |
| 160 | "--source", "fix", "--target", "main", "--title", "'needs a look'"); code != 0 { | |
| 161 | t.Fatalf("mr create: %s", errOut) | |
| 162 | } | |
| 163 | // And an issue assigned to alice. | |
| 164 | if _, errOut, code := inst.ssh(t, bobKey, "", "issue", "create", "alice/app", "--title", "'please handle'"); code != 0 { | |
| 165 | t.Fatalf("issue create: %s", errOut) | |
| 166 | } | |
| 167 | if _, errOut, code := inst.ssh(t, aliceKey, "", "issue", "assign", "alice/app", "1", "--add", "alice"); code != 0 { | |
| 168 | t.Fatalf("assign: %s", errOut) | |
| 169 | } | |
| 170 | ||
| 171 | _, body := browserGet(t, inst.login(t, aliceKey), inst.base()+"/") | |
| 172 | if !strings.Contains(body, "needs a look") { | |
| 173 | t.Fatalf("review queue missing the MR:\n%s", body) | |
| 174 | } | |
| 175 | if !strings.Contains(body, "please handle") { | |
| 176 | t.Fatalf("assigned issues missing the issue:\n%s", body) | |
| 177 | } | |
| 178 | // The feed reports what happened, phrased and linked. | |
| 179 | for _, want := range []string{"opened merge request", "opened issue", `href="/alice/app/mrs/1"`} { | |
| 180 | if !strings.Contains(body, want) { | |
| 181 | t.Fatalf("feed missing %q:\n%s", want, body) | |
| 182 | } | |
| 183 | } | |
| 184 | ||
| 185 | // Once alice reviews, the merge request leaves her queue. | |
| 186 | if _, errOut, code := inst.ssh(t, aliceKey, "", "mr", "review", "alice/app", "1", "--approve"); code != 0 { | |
| 187 | t.Fatalf("review: %s", errOut) | |
| 188 | } | |
| 189 | _, after := browserGet(t, inst.login(t, aliceKey), inst.base()+"/") | |
| 190 | queue := after[strings.Index(after, "Waiting on your review"):strings.Index(after, "Assigned to you")] | |
| 191 | if strings.Contains(queue, "needs a look") { | |
| 192 | t.Fatalf("reviewed MR still waiting:\n%s", queue) | |
| 193 | } | |
| 194 | } | |
e2e/design_test.go +47 −1
| @@ -189,7 +189,7 @@ func TestWebInteractions(t *testing.T) { | ||
| 189 | 189 | if !strings.Contains(body, "★ Pinned") { |
| 190 | 190 | t.Fatal("repo header not pinned") |
| 191 | 191 | } |
| 192 | if _, body = browserGet(t, browser, inst.base()+"/"); !strings.Contains(body, "theorg<span") { | |
| 192 | if _, body = browserGet(t, browser, inst.base()+"/"); !strings.Contains(body, ">theorg/</span>webborn") { | |
| 193 | 193 | t.Fatal("dashboard missing pinned repo") |
| 194 | 194 | } |
| 195 | 195 | browserPost(t, browser, inst.base()+"/theorg/webborn/pin", url.Values{}) |
| @@ -247,3 +247,49 @@ func TestCommitParentLinks(t *testing.T) { | ||
| 247 | 247 | t.Fatal("root commit shows a parent") |
| 248 | 248 | } |
| 249 | 249 | } |
| 250 | ||
| 251 | // TestAuthorNamesResolve checks that a commit whose author address is | |
| 252 | // verified here displays the account's name rather than whatever git | |
| 253 | // config carried, and that an unknown address keeps its own name. | |
| 254 | func TestAuthorNamesResolve(t *testing.T) { | |
| 255 | inst := startInstance(t) | |
| 256 | aliceKey := inst.newKey(t, "alice") | |
| 257 | inst.admin(t, "admin", "user", "create", "alice", | |
| 258 | "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified") | |
| 259 | if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 { | |
| 260 | t.Fatalf("repo create: %s", errOut) | |
| 261 | } | |
| 262 | work := t.TempDir() | |
| 263 | env := inst.gitEnv(aliceKey) | |
| 264 | mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w") | |
| 265 | dir := filepath.Join(work, "w") | |
| 266 | ||
| 267 | // One commit from the account's verified address under a different | |
| 268 | // display name, one from an address nobody has proven. | |
| 269 | known := append(append([]string{}, env...), | |
| 270 | "GIT_AUTHOR_NAME=Alice Q. Longname", "GIT_AUTHOR_EMAIL=alice@example.test", | |
| 271 | "GIT_COMMITTER_NAME=Alice Q. Longname", "GIT_COMMITTER_EMAIL=alice@example.test") | |
| 272 | os.WriteFile(filepath.Join(dir, "a.txt"), []byte("a\n"), 0o644) | |
| 273 | mustGit(t, dir, known, "checkout", "-q", "-b", "main") | |
| 274 | mustGit(t, dir, known, "add", ".") | |
| 275 | mustGit(t, dir, known, "commit", "-q", "-m", "from the account") | |
| 276 | stranger := append(append([]string{}, env...), | |
| 277 | "GIT_AUTHOR_NAME=Outside Person", "GIT_AUTHOR_EMAIL=outside@nowhere.test", | |
| 278 | "GIT_COMMITTER_NAME=Outside Person", "GIT_COMMITTER_EMAIL=outside@nowhere.test") | |
| 279 | os.WriteFile(filepath.Join(dir, "b.txt"), []byte("b\n"), 0o644) | |
| 280 | mustGit(t, dir, stranger, "add", ".") | |
| 281 | mustGit(t, dir, stranger, "commit", "-q", "-m", "from a stranger") | |
| 282 | mustGit(t, dir, known, "push", "-q", "origin", "main") | |
| 283 | ||
| 284 | // The log shows the account name for the verified address only. | |
| 285 | _, body := inst.get(t, "/alice/app/log") | |
| 286 | if strings.Contains(body, "Alice Q. Longname") { | |
| 287 | t.Fatalf("log showed the git config name for a known address:\n%s", body) | |
| 288 | } | |
| 289 | if !strings.Contains(body, "Outside Person") { | |
| 290 | t.Fatalf("log lost an unknown author's name:\n%s", body) | |
| 291 | } | |
| 292 | if !strings.Contains(body, "alice") { | |
| 293 | t.Fatalf("log missing the account name:\n%s", body) | |
| 294 | } | |
| 295 | } | |
internal/gitutil/lastcommit.go +8 −7
| @@ -13,6 +13,7 @@ type EntryCommit struct { | ||
| 13 | 13 | SHA string |
| 14 | 14 | Subject string |
| 15 | 15 | Author string |
| 16 | Email string | |
| 16 | 17 | When time.Time |
| 17 | 18 | } |
| 18 | 19 | |
| @@ -44,7 +45,7 @@ func LastCommits(dir, ref, path string, names []string) map[string]EntryCommit { | ||
| 44 | 45 | } |
| 45 | 46 | |
| 46 | 47 | args := []string{"-C", dir, "log", "--first-parent", "--name-only", |
| 47 | "--format=%x1e%H%x1f%ct%x1f%an%x1f%s", "-n", strconv.Itoa(lastCommitScan), ref} | |
| 48 | "--format=%x1e%H%x1f%ct%x1f%an%x1f%ae%x1f%s", "-n", strconv.Itoa(lastCommitScan), ref} | |
| 48 | 49 | if prefix != "" { |
| 49 | 50 | args = append(args, "--", strings.TrimSuffix(prefix, "/")) |
| 50 | 51 | } |
| @@ -89,14 +90,14 @@ func LastCommits(dir, ref, path string, names []string) map[string]EntryCommit { | ||
| 89 | 90 | return out |
| 90 | 91 | } |
| 91 | 92 | |
| 92 | // parseCommitHeader reads sha, commit time, author, and subject, unit | |
| 93 | // separated so a subject containing spaces stays intact. | |
| 93 | // parseCommitHeader reads sha, commit time, author name and address, and | |
| 94 | // subject, unit separated so a subject containing spaces stays intact. | |
| 94 | 95 | func parseCommitHeader(s string) EntryCommit { |
| 95 | f := strings.SplitN(s, "\x1f", 4) | |
| 96 | if len(f) != 4 { | |
| 96 | f := strings.SplitN(s, "\x1f", 5) | |
| 97 | if len(f) != 5 { | |
| 97 | 98 | return EntryCommit{} |
| 98 | 99 | } |
| 99 | c := EntryCommit{SHA: f[0], Author: f[2], Subject: f[3]} | |
| 100 | c := EntryCommit{SHA: f[0], Author: f[2], Email: f[3], Subject: f[4]} | |
| 100 | 101 | if n, err := strconv.ParseInt(f[1], 10, 64); err == nil { |
| 101 | 102 | c.When = time.Unix(n, 0).UTC() |
| 102 | 103 | } |
| @@ -107,7 +108,7 @@ func parseCommitHeader(s string) EntryCommit { | ||
| 107 | 108 | // answers "who touched this repository last". |
| 108 | 109 | func TipCommit(dir, ref string) EntryCommit { |
| 109 | 110 | out, err := exec.Command("git", "-C", dir, "log", "-1", |
| 110 | "--format=%H%x1f%ct%x1f%an%x1f%s", ref).Output() | |
| 111 | "--format=%H%x1f%ct%x1f%an%x1f%ae%x1f%s", ref).Output() | |
| 111 | 112 | if err != nil { |
| 112 | 113 | return EntryCommit{} |
| 113 | 114 | } |
internal/httpd/badge.go added +89
| @@ -0,0 +1,89 @@ | ||
| 1 | package httpd | |
| 2 | ||
| 3 | import ( | |
| 4 | "fmt" | |
| 5 | "net/http" | |
| 6 | "strings" | |
| 7 | ) | |
| 8 | ||
| 9 | // Status badges. A badge is a small SVG served for public repositories, | |
| 10 | // so a README on any host can show whether the latest build passed. | |
| 11 | // Private repositories 404 like everywhere else — a badge must not leak | |
| 12 | // that a repo exists, let alone its state. | |
| 13 | ||
| 14 | // badgeColors are the shield fills per build state. | |
| 15 | var badgeColors = map[string]string{ | |
| 16 | "success": "#2da44e", | |
| 17 | "failure": "#cf222e", | |
| 18 | "running": "#bf8700", | |
| 19 | "pending": "#bf8700", | |
| 20 | "unknown": "#6b7280", | |
| 21 | } | |
| 22 | ||
| 23 | // badgeWidth approximates Verdana 11px advance so the pill fits its text | |
| 24 | // without shipping a font metric table. | |
| 25 | func badgeWidth(s string) int { | |
| 26 | w := 0 | |
| 27 | for _, r := range s { | |
| 28 | switch { | |
| 29 | case strings.ContainsRune("iljtIJ1.,:;'", r): | |
| 30 | w += 4 | |
| 31 | case strings.ContainsRune("mwMW", r): | |
| 32 | w += 10 | |
| 33 | case r >= 'A' && r <= 'Z': | |
| 34 | w += 8 | |
| 35 | default: | |
| 36 | w += 7 | |
| 37 | } | |
| 38 | } | |
| 39 | return w + 10 | |
| 40 | } | |
| 41 | ||
| 42 | // badgeSVG renders a two-part pill: a grey label and a coloured state. | |
| 43 | func badgeSVG(label, state string) string { | |
| 44 | color := badgeColors[state] | |
| 45 | if color == "" { | |
| 46 | color = badgeColors["unknown"] | |
| 47 | } | |
| 48 | lw, sw := badgeWidth(label), badgeWidth(state) | |
| 49 | total := lw + sw | |
| 50 | return fmt.Sprintf(`<svg xmlns="http://www.w3.org/2000/svg" width="%d" height="20" role="img" aria-label="%s: %s"> | |
| 51 | <title>%s: %s</title> | |
| 52 | <rect width="%d" height="20" rx="3" fill="#444d56"/> | |
| 53 | <rect x="%d" width="%d" height="20" rx="3" fill="%s"/> | |
| 54 | <rect x="%d" width="4" height="20" fill="%s"/> | |
| 55 | <g fill="#fff" text-anchor="middle" font-family="Verdana,DejaVu Sans,sans-serif" font-size="11"> | |
| 56 | <text x="%d" y="14">%s</text> | |
| 57 | <text x="%d" y="14">%s</text> | |
| 58 | </g> | |
| 59 | </svg>`, total, label, state, label, state, | |
| 60 | total, lw, sw, color, lw, color, lw/2, label, lw+sw/2, state) | |
| 61 | } | |
| 62 | ||
| 63 | // buildBadge answers GET /{owner}/{repo}/badge/build.svg[?job=name]. | |
| 64 | func (s *Server) buildBadge(w http.ResponseWriter, r *http.Request) { | |
| 65 | repo, ok := s.publicRepo(r.PathValue("owner"), strings.TrimSuffix(r.PathValue("repo"), ".git")) | |
| 66 | if !ok { | |
| 67 | http.NotFound(w, r) | |
| 68 | return | |
| 69 | } | |
| 70 | job := r.URL.Query().Get("job") | |
| 71 | label := "build" | |
| 72 | if job != "" { | |
| 73 | label = job | |
| 74 | } | |
| 75 | state := "unknown" | |
| 76 | if b, err := s.st.LatestBuild(repo.ID, job); err == nil { | |
| 77 | state = b.Status | |
| 78 | } | |
| 79 | writeBadge(w, label, state) | |
| 80 | } | |
| 81 | ||
| 82 | func writeBadge(w http.ResponseWriter, label, state string) { | |
| 83 | w.Header().Set("Content-Type", "image/svg+xml; charset=utf-8") | |
| 84 | // Badges are read by other people's caches; keep them briefly fresh | |
| 85 | // rather than pinned to a stale result. | |
| 86 | w.Header().Set("Cache-Control", "max-age=60, must-revalidate") | |
| 87 | w.Header().Set("X-Content-Type-Options", "nosniff") | |
| 88 | fmt.Fprint(w, badgeSVG(label, state)) | |
| 89 | } | |
internal/httpd/control.go +65
| @@ -6,6 +6,7 @@ import ( | ||
| 6 | 6 | "strings" |
| 7 | 7 | |
| 8 | 8 | "gitbay.org/gitbay/internal/control" |
| 9 | "gitbay.org/gitbay/internal/gitutil" | |
| 9 | 10 | "gitbay.org/gitbay/internal/protocol" |
| 10 | 11 | "gitbay.org/gitbay/internal/store" |
| 11 | 12 | ) |
| @@ -73,3 +74,67 @@ func (s *Server) runControlJSON(u store.User, argv []string) (data map[string]an | ||
| 73 | 74 | } |
| 74 | 75 | return env.Data, "", true |
| 75 | 76 | } |
| 77 | ||
| 78 | // authorNames maps commit author addresses to account names for one | |
| 79 | // request. A commit carries whatever name git was configured with; when | |
| 80 | // the address is a verified address here, the account's own name is the | |
| 81 | // truthful one to show, and it links somewhere. | |
| 82 | type authorNames struct { | |
| 83 | st *store.Store | |
| 84 | cache map[string]string | |
| 85 | } | |
| 86 | ||
| 87 | func (s *Server) authorNames() *authorNames { | |
| 88 | return &authorNames{st: s.st, cache: map[string]string{}} | |
| 89 | } | |
| 90 | ||
| 91 | // name returns the account name for an address, or the commit's own | |
| 92 | // author name when no account has verified it. | |
| 93 | func (a *authorNames) name(email, fallback string) string { | |
| 94 | if email == "" { | |
| 95 | return fallback | |
| 96 | } | |
| 97 | if got, ok := a.cache[email]; ok { | |
| 98 | if got == "" { | |
| 99 | return fallback | |
| 100 | } | |
| 101 | return got | |
| 102 | } | |
| 103 | name, _ := a.st.UsernameByVerifiedEmail(email) | |
| 104 | a.cache[email] = name | |
| 105 | if name == "" { | |
| 106 | return fallback | |
| 107 | } | |
| 108 | return name | |
| 109 | } | |
| 110 | ||
| 111 | // known reports whether the address belongs to an account, so callers can | |
| 112 | // decide to link the name. | |
| 113 | func (a *authorNames) known(email string) bool { | |
| 114 | if email == "" { | |
| 115 | return false | |
| 116 | } | |
| 117 | if got, ok := a.cache[email]; ok { | |
| 118 | return got != "" | |
| 119 | } | |
| 120 | name, _ := a.st.UsernameByVerifiedEmail(email) | |
| 121 | a.cache[email] = name | |
| 122 | return name != "" | |
| 123 | } | |
| 124 | ||
| 125 | // namedCommits rewrites listing authors to account names where the | |
| 126 | // address is verified here. | |
| 127 | func (s *Server) namedCommits(m map[string]gitutil.EntryCommit) map[string]gitutil.EntryCommit { | |
| 128 | names := s.authorNames() | |
| 129 | for k, c := range m { | |
| 130 | c.Author = names.name(c.Email, c.Author) | |
| 131 | m[k] = c | |
| 132 | } | |
| 133 | return m | |
| 134 | } | |
| 135 | ||
| 136 | // namedTip does the same for the single commit above a tree listing. | |
| 137 | func (s *Server) namedTip(c gitutil.EntryCommit) gitutil.EntryCommit { | |
| 138 | c.Author = s.authorNames().name(c.Email, c.Author) | |
| 139 | return c | |
| 140 | } | |
internal/httpd/feed.go added +89
| @@ -0,0 +1,89 @@ | ||
| 1 | package httpd | |
| 2 | ||
| 3 | import ( | |
| 4 | "encoding/json" | |
| 5 | "fmt" | |
| 6 | "strings" | |
| 7 | ||
| 8 | "gitbay.org/gitbay/internal/store" | |
| 9 | ) | |
| 10 | ||
| 11 | // feedLine is one activity entry, already phrased and linked. | |
| 12 | type feedLine struct { | |
| 13 | Actor string | |
| 14 | Verb string // "opened issue", "merged" | |
| 15 | Ref string // "#12", "!35", "v0.4.0" | |
| 16 | Repo string | |
| 17 | URL string | |
| 18 | When string | |
| 19 | } | |
| 20 | ||
| 21 | // feedLines turns stored events into readable lines. An unknown kind | |
| 22 | // still shows: the feed says what happened even for events added later. | |
| 23 | func feedLines(events []store.FeedEvent) []feedLine { | |
| 24 | out := make([]feedLine, 0, len(events)) | |
| 25 | for _, e := range events { | |
| 26 | var d struct { | |
| 27 | Number int64 `json:"number"` | |
| 28 | Job string `json:"job"` | |
| 29 | Tag string `json:"tag"` | |
| 30 | } | |
| 31 | json.Unmarshal([]byte(e.Data), &d) | |
| 32 | ||
| 33 | l := feedLine{Actor: e.Actor, Repo: e.RepoPath, When: e.CreatedAt} | |
| 34 | if l.Actor == "" { | |
| 35 | l.Actor = "gitbay" | |
| 36 | } | |
| 37 | kind, rest, _ := strings.Cut(e.Kind, ".") | |
| 38 | switch kind { | |
| 39 | case "issue": | |
| 40 | l.Verb, l.Ref = issueVerb(rest), fmt.Sprintf("#%d", d.Number) | |
| 41 | l.URL = fmt.Sprintf("/%s/issues/%d", e.RepoPath, d.Number) | |
| 42 | case "mr": | |
| 43 | l.Verb, l.Ref = mrVerb(rest), fmt.Sprintf("!%d", d.Number) | |
| 44 | l.URL = fmt.Sprintf("/%s/mrs/%d", e.RepoPath, d.Number) | |
| 45 | case "build": | |
| 46 | l.Verb, l.Ref = "build "+rest, d.Job | |
| 47 | l.URL = fmt.Sprintf("/%s/builds/%d", e.RepoPath, d.Number) | |
| 48 | case "release": | |
| 49 | l.Verb, l.Ref = "released", d.Tag | |
| 50 | l.URL = fmt.Sprintf("/%s/releases", e.RepoPath) | |
| 51 | case "repo": | |
| 52 | l.Verb = "repository " + rest | |
| 53 | l.URL = "/" + e.RepoPath | |
| 54 | default: | |
| 55 | l.Verb = e.Kind | |
| 56 | l.URL = "/" + e.RepoPath | |
| 57 | } | |
| 58 | out = append(out, l) | |
| 59 | } | |
| 60 | return out | |
| 61 | } | |
| 62 | ||
| 63 | func issueVerb(s string) string { | |
| 64 | switch s { | |
| 65 | case "created": | |
| 66 | return "opened issue" | |
| 67 | case "closed": | |
| 68 | return "closed issue" | |
| 69 | case "reopened": | |
| 70 | return "reopened issue" | |
| 71 | case "commented": | |
| 72 | return "commented on" | |
| 73 | } | |
| 74 | return "issue " + s | |
| 75 | } | |
| 76 | ||
| 77 | func mrVerb(s string) string { | |
| 78 | switch s { | |
| 79 | case "created": | |
| 80 | return "opened merge request" | |
| 81 | case "merged": | |
| 82 | return "merged" | |
| 83 | case "commented": | |
| 84 | return "commented on" | |
| 85 | case "closed": | |
| 86 | return "closed merge request" | |
| 87 | } | |
| 88 | return "merge request " + s | |
| 89 | } | |
internal/httpd/routes.go +1
| @@ -58,6 +58,7 @@ func (s *Server) Routes() []Route { | ||
| 58 | 58 | Route{Method: "GET", Pattern: "/{owner}/{repo}/wiki/{page}", Handler: s.wiki}, |
| 59 | 59 | Route{Method: "GET", Pattern: "/{owner}/{repo}/releases", Handler: s.releases}, |
| 60 | 60 | Route{Method: "GET", Pattern: "/{owner}/{repo}/builds", Handler: s.builds}, |
| 61 | Route{Method: "GET", Pattern: "/{owner}/{repo}/badge/build.svg", Handler: s.buildBadge}, | |
| 61 | 62 | Route{Method: "GET", Pattern: "/{owner}/{repo}/builds/{n}", Handler: s.build}, |
| 62 | 63 | Route{Method: "GET", Pattern: "/{owner}/{repo}/releases/download/{tag}/{name}", Handler: s.releaseAsset}, |
| 63 | 64 | Route{Method: "GET", Pattern: "/{owner}/{repo}/raw/{ref}/{path...}", Handler: s.raw}, |
internal/httpd/web.go +17 −9
| @@ -151,12 +151,18 @@ func (s *Server) dashboard(w http.ResponseWriter, r *http.Request, viewer store. | ||
| 151 | 151 | } |
| 152 | 152 | mrs, _ := s.st.DashboardMRs(viewer.ID) |
| 153 | 153 | issues, _ := s.st.DashboardIssues(viewer.ID) |
| 154 | reviews, _ := s.st.ReviewQueue(viewer.ID) | |
| 155 | assigned, _ := s.st.AssignedIssues(viewer.ID) | |
| 156 | events, _ := s.st.RecentEvents(viewer.ID, 20) | |
| 154 | 157 | s.render(w, "dashboard.html", struct { |
| 155 | 158 | basePage |
| 156 | Pinned []store.Repo | |
| 157 | MRs []store.DashboardItem | |
| 158 | Issues []store.DashboardItem | |
| 159 | }{s.baseFor(viewer), visible, mrs, issues}) | |
| 159 | Pinned []store.Repo | |
| 160 | Reviews []store.DashboardItem | |
| 161 | Assigned []store.DashboardItem | |
| 162 | MRs []store.DashboardItem | |
| 163 | Issues []store.DashboardItem | |
| 164 | Feed []feedLine | |
| 165 | }{s.baseFor(viewer), visible, reviews, assigned, mrs, issues, feedLines(events)}) | |
| 160 | 166 | } |
| 161 | 167 | |
| 162 | 168 | func (s *Server) explore(w http.ResponseWriter, r *http.Request) { |
| @@ -477,8 +483,8 @@ func (s *Server) renderTree(w http.ResponseWriter, r *http.Request, p repoPage, | ||
| 477 | 483 | Tip gitutil.EntryCommit |
| 478 | 484 | }{p, crumbs(p, "tree", dirPath), prefix, dirPath, "tree", entries, branches, |
| 479 | 485 | readmeName, readmeHTML, |
| 480 | gitutil.LastCommits(p.Dir, p.Ref, dirPath, names), | |
| 481 | gitutil.TipCommit(p.Dir, p.Ref)}) | |
| 486 | s.namedCommits(gitutil.LastCommits(p.Dir, p.Ref, dirPath, names)), | |
| 487 | s.namedTip(gitutil.TipCommit(p.Dir, p.Ref))}) | |
| 482 | 488 | } |
| 483 | 489 | |
| 484 | 490 | func (s *Server) blob(w http.ResponseWriter, r *http.Request) { |
| @@ -1198,13 +1204,14 @@ func (s *Server) log(w http.ResponseWriter, r *http.Request) { | ||
| 1198 | 1204 | SHA, ShortSHA, Subject, AuthorName, AuthorEmail, Date string |
| 1199 | 1205 | Sig sigView |
| 1200 | 1206 | } |
| 1207 | names := s.authorNames() | |
| 1201 | 1208 | var rows []row |
| 1202 | 1209 | for _, sha := range shas { |
| 1203 | 1210 | v, parsed := s.sigFor(p.Repo, p.Dir, sha) |
| 1204 | 1211 | rw := row{SHA: sha, ShortSHA: sha[:10], Sig: v} |
| 1205 | 1212 | if parsed != nil { |
| 1206 | 1213 | rw.Subject = parsed.Subject |
| 1207 | rw.AuthorName = parsed.AuthorName | |
| 1214 | rw.AuthorName = names.name(parsed.AuthorEmail, parsed.AuthorName) | |
| 1208 | 1215 | rw.AuthorEmail = parsed.AuthorEmail |
| 1209 | 1216 | rw.Date = time.Unix(parsed.AuthorUnix, 0).UTC().Format("2006-01-02") |
| 1210 | 1217 | } |
| @@ -1253,7 +1260,7 @@ func (s *Server) commit(w http.ResponseWriter, r *http.Request) { | ||
| 1253 | 1260 | Sig sigView |
| 1254 | 1261 | Checks []store.CommitStatus |
| 1255 | 1262 | DiffLines []diffLine |
| 1256 | }{p, full, full[:10], parsed.AuthorName, parsed.AuthorEmail, committerEmail, | |
| 1263 | }{p, full, full[:10], s.authorNames().name(parsed.AuthorEmail, parsed.AuthorName), parsed.AuthorEmail, committerEmail, | |
| 1257 | 1264 | time.Unix(parsed.AuthorUnix, 0).UTC().Format(time.RFC3339), msg, |
| 1258 | 1265 | gitutil.Parents(p.Dir, full), v, checks, lines}) |
| 1259 | 1266 | } |
| @@ -1478,6 +1485,7 @@ func (s *Server) mr(w http.ResponseWriter, r *http.Request) { | ||
| 1478 | 1485 | SHA, ShortSHA, Subject, AuthorName, Date string |
| 1479 | 1486 | Sig sigView |
| 1480 | 1487 | } |
| 1488 | mrNames := s.authorNames() | |
| 1481 | 1489 | var commits []commitRow |
| 1482 | 1490 | if base != "" { |
| 1483 | 1491 | const maxMRCommits = 100 |
| @@ -1490,7 +1498,7 @@ func (s *Server) mr(w http.ResponseWriter, r *http.Request) { | ||
| 1490 | 1498 | cr := commitRow{SHA: sha, ShortSHA: sha[:10], Sig: v} |
| 1491 | 1499 | if parsed != nil { |
| 1492 | 1500 | cr.Subject = parsed.Subject |
| 1493 | cr.AuthorName = parsed.AuthorName | |
| 1501 | cr.AuthorName = mrNames.name(parsed.AuthorEmail, parsed.AuthorName) | |
| 1494 | 1502 | cr.Date = time.Unix(parsed.AuthorUnix, 0).UTC().Format("2006-01-02") |
| 1495 | 1503 | } |
| 1496 | 1504 | commits = append(commits, cr) |
internal/store/activity.go +14
| @@ -18,6 +18,20 @@ func (s *Store) UserIDByVerifiedEmail(address string) (int64, bool) { | ||
| 18 | 18 | return id, true |
| 19 | 19 | } |
| 20 | 20 | |
| 21 | // UsernameByVerifiedEmail resolves a commit author address to the account | |
| 22 | // that has proven it, so the forge can show its own name for a person | |
| 23 | // rather than whatever git config happened to be set. | |
| 24 | func (s *Store) UsernameByVerifiedEmail(address string) (string, bool) { | |
| 25 | var name string | |
| 26 | err := s.DB.QueryRow(` | |
| 27 | SELECT u.username FROM emails e JOIN users u ON u.id = e.user_id | |
| 28 | WHERE e.address = ? AND e.verified_at IS NOT NULL`, address).Scan(&name) | |
| 29 | if err != nil { | |
| 30 | return "", false | |
| 31 | } | |
| 32 | return name, true | |
| 33 | } | |
| 34 | ||
| 21 | 35 | // RecordCommitActivity is idempotent per (repo, sha); it reports whether |
| 22 | 36 | // this call recorded a new row. |
| 23 | 37 | func (s *Store) RecordCommitActivity(repoID int64, sha string, userID int64, day string) bool { |
internal/store/builds.go +17
| @@ -147,3 +147,20 @@ func (s *Store) BuildLog(id int64) ([]byte, error) { | ||
| 147 | 147 | } |
| 148 | 148 | return log, err |
| 149 | 149 | } |
| 150 | ||
| 151 | // LatestBuild returns the newest build for a repo, optionally narrowed to | |
| 152 | // one job. It is what a status badge reports. | |
| 153 | func (s *Store) LatestBuild(repoID int64, job string) (Build, error) { | |
| 154 | q := buildSelect + " WHERE repo_id = ?" | |
| 155 | args := []any{repoID} | |
| 156 | if job != "" { | |
| 157 | q += " AND job = ?" | |
| 158 | args = append(args, job) | |
| 159 | } | |
| 160 | q += " ORDER BY number DESC LIMIT 1" | |
| 161 | b, err := scanBuild(s.DB.QueryRow(q, args...)) | |
| 162 | if errors.Is(err, sql.ErrNoRows) { | |
| 163 | return b, ErrNotFound | |
| 164 | } | |
| 165 | return b, err | |
| 166 | } | |
internal/store/dashboard.go +66
| @@ -149,3 +149,69 @@ func (s *Store) OpenCounts(repoID int64) (issues, mrs int) { | ||
| 149 | 149 | repoID).Scan(&mrs) |
| 150 | 150 | return |
| 151 | 151 | } |
| 152 | ||
| 153 | // AssignedIssues returns open issues assigned to the user, wherever they | |
| 154 | // live. Assignment is a direct request for someone's attention, so it is | |
| 155 | // not narrowed by the involvement rule the other lists use. | |
| 156 | func (s *Store) AssignedIssues(userID int64) ([]DashboardItem, error) { | |
| 157 | return s.dashboardQuery(` | |
| 158 | SELECT COALESCE(u.username, o.name) || '/' || r.name, | |
| 159 | x.number, x.title, au.username, x.state, x.updated_at | |
| 160 | FROM issues x | |
| 161 | JOIN repos r ON r.id = x.repo_id | |
| 162 | LEFT JOIN users u ON r.owner_kind = 'user' AND u.id = r.owner_id | |
| 163 | LEFT JOIN orgs o ON r.owner_kind = 'org' AND o.id = r.owner_id | |
| 164 | JOIN users au ON au.id = x.author_id | |
| 165 | WHERE x.state = 'open' | |
| 166 | AND EXISTS (SELECT 1 FROM issue_assignees ia | |
| 167 | WHERE ia.issue_id = x.id AND ia.user_id = ?1) | |
| 168 | ORDER BY x.updated_at DESC LIMIT 20`, userID) | |
| 169 | } | |
| 170 | ||
| 171 | // FeedEvent is one line of the dashboard's activity feed. | |
| 172 | type FeedEvent struct { | |
| 173 | RepoPath string | |
| 174 | Actor string | |
| 175 | Kind string | |
| 176 | Data string | |
| 177 | CreatedAt string | |
| 178 | } | |
| 179 | ||
| 180 | // RecentEvents returns activity on repositories the user can reach. Push | |
| 181 | // events are excluded: they repeat what the commit lists already show. | |
| 182 | func (s *Store) RecentEvents(userID int64, limit int) ([]FeedEvent, error) { | |
| 183 | rows, err := s.DB.Query(` | |
| 184 | SELECT COALESCE(u.username, o.name) || '/' || r.name, | |
| 185 | COALESCE(ac.username, ''), e.kind, e.data_json, e.created_at | |
| 186 | FROM events e | |
| 187 | JOIN repos r ON r.id = e.repo_id | |
| 188 | LEFT JOIN users u ON r.owner_kind = 'user' AND u.id = r.owner_id | |
| 189 | LEFT JOIN orgs o ON r.owner_kind = 'org' AND o.id = r.owner_id | |
| 190 | LEFT JOIN users ac ON ac.id = e.actor_id | |
| 191 | WHERE e.kind <> 'push' AND ( | |
| 192 | (r.owner_kind = 'user' AND r.owner_id = ?1) | |
| 193 | OR EXISTS (SELECT 1 FROM repo_access a | |
| 194 | WHERE a.repo_id = r.id AND a.subject_kind = 'user' AND a.subject_id = ?1) | |
| 195 | OR EXISTS (SELECT 1 FROM org_members mm | |
| 196 | JOIN orgs oo ON oo.id = mm.org_id | |
| 197 | WHERE r.owner_kind = 'org' AND mm.org_id = r.owner_id AND mm.user_id = ?1 | |
| 198 | AND (mm.role = 'admin' OR oo.members_role <> 'none')) | |
| 199 | OR EXISTS (SELECT 1 FROM team_repos tr | |
| 200 | JOIN team_members tm ON tm.team_id = tr.team_id AND tm.user_id = ?1 | |
| 201 | WHERE tr.repo_id = r.id) | |
| 202 | ) | |
| 203 | ORDER BY e.id DESC LIMIT ?2`, userID, limit) | |
| 204 | if err != nil { | |
| 205 | return nil, err | |
| 206 | } | |
| 207 | defer rows.Close() | |
| 208 | var out []FeedEvent | |
| 209 | for rows.Next() { | |
| 210 | var e FeedEvent | |
| 211 | if err := rows.Scan(&e.RepoPath, &e.Actor, &e.Kind, &e.Data, &e.CreatedAt); err != nil { | |
| 212 | return nil, err | |
| 213 | } | |
| 214 | out = append(out, e) | |
| 215 | } | |
| 216 | return out, rows.Err() | |
| 217 | } | |
internal/web/static/style.css +3
| @@ -326,6 +326,9 @@ nav.tabs { | ||
| 326 | 326 | gap: var(--sp-1); |
| 327 | 327 | margin-top: var(--sp-4); |
| 328 | 328 | overflow-x: auto; |
| 329 | /* with overflow-y left visible the browser computes it to auto, which | |
| 330 | is a vertical drag on touch; the strip scrolls sideways only. */ | |
| 331 | overflow-y: hidden; | |
| 329 | 332 | scrollbar-width: none; |
| 330 | 333 | } |
| 331 | 334 | nav.tabs::-webkit-scrollbar { display: none; } |
internal/web/templates/builds.html +5
| @@ -1,6 +1,11 @@ | ||
| 1 | 1 | {{define "title"}}builds · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} |
| 2 | 2 | {{define "content"}} |
| 3 | 3 | <h1>Builds</h1> |
| 4 | <details class="editbox"><summary>Status badge</summary> | |
| 5 | <p class="meta">Paste into a README; it shows the newest build's state.</p> | |
| 6 | <pre class="code">[](https://{{.Host}}/{{.Repo.OwnerName}}/{{.Repo.Name}}/builds)</pre> | |
| 7 | <p class="meta">Add <code>?job=name</code> for one job.</p> | |
| 8 | </details> | |
| 4 | 9 | <ul class="loglist"> |
| 5 | 10 | {{range .Builds}}<li> |
| 6 | 11 | <div class="commitmain"> |
internal/web/templates/dashboard.html +37 −24
| @@ -1,33 +1,46 @@ | ||
| 1 | 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 <a href="/{{.Viewer}}">{{.Viewer}}</a></p> | |
| 7 | </div> | |
| 8 | {{if .Pinned}}<h2>Pinned</h2> | |
| 9 | <ul class="pinlist"> | |
| 10 | {{range .Pinned}}<li><a href="/{{.OwnerName}}/{{.Name}}">{{.OwnerName}}<span class="sep">/</span><strong>{{.Name}}</strong></a>{{if eq .Visibility "private"}} <span class="chip chip-neutral">private</span>{{end}}</li> | |
| 11 | {{end}} | |
| 12 | </ul>{{end}} | |
| 13 | <h2>Open merge requests <span class="count">{{len .MRs}}</span></h2> | |
| 2 | {{define "itemlist"}} | |
| 14 | 3 | <ul class="issuelist"> |
| 15 | {{range .MRs}}<li> | |
| 4 | {{range .Items}}<li> | |
| 16 | 5 | <div class="issuemain"> |
| 17 | <p class="title"><a href="/{{.RepoPath}}/mrs/{{.Number}}">{{.RepoPath}}!{{.Number}} {{.Title}}</a></p> | |
| 18 | <p class="meta"><a href="/{{.Author}}">{{.Author}}</a> · {{when .UpdatedAt}}{{if eq .State "source_gone"}} · <span class="chip chip-source_gone">source gone</span>{{end}}</p> | |
| 6 | <p class="title"><a href="/{{.RepoPath}}/{{$.Kind}}/{{.Number}}">{{.Title}}</a></p> | |
| 7 | <p class="meta">{{.RepoPath}}{{if eq $.Kind "mrs"}}!{{else}}#{{end}}{{.Number}} · <a href="/{{.Author}}">{{.Author}}</a> · {{when .UpdatedAt}}{{if eq .State "source_gone"}} · <span class="chip chip-source_gone">source gone</span>{{end}}</p> | |
| 19 | 8 | </div> |
| 20 | 9 | </li> |
| 21 | {{else}}<li class="empty">no open merge requests</li>{{end}} | |
| 10 | {{else}}<li class="empty">{{$.Empty}}</li>{{end}} | |
| 22 | 11 | </ul> |
| 12 | {{end}} | |
| 13 | {{define "content"}} | |
| 14 | <h1>Dashboard</h1> | |
| 15 | ||
| 16 | <div class="withaside"> | |
| 17 | <div class="mainside"> | |
| 18 | ||
| 19 | <h2>Waiting on your review <span class="count">{{len .Reviews}}</span></h2> | |
| 20 | {{template "itemlist" dict "Items" .Reviews "Kind" "mrs" "Empty" "Nothing waiting on you"}} | |
| 21 | ||
| 22 | <h2>Assigned to you <span class="count">{{len .Assigned}}</span></h2> | |
| 23 | {{template "itemlist" dict "Items" .Assigned "Kind" "issues" "Empty" "Nothing assigned to you"}} | |
| 24 | ||
| 25 | <h2>Open merge requests <span class="count">{{len .MRs}}</span></h2> | |
| 26 | {{template "itemlist" dict "Items" .MRs "Kind" "mrs" "Empty" "No open merge requests"}} | |
| 27 | ||
| 23 | 28 | <h2>Open issues <span class="count">{{len .Issues}}</span></h2> |
| 24 | <ul class="issuelist"> | |
| 25 | {{range .Issues}}<li> | |
| 26 | <div class="issuemain"> | |
| 27 | <p class="title"><a href="/{{.RepoPath}}/issues/{{.Number}}">{{.RepoPath}}#{{.Number}} {{.Title}}</a></p> | |
| 28 | <p class="meta"><a href="/{{.Author}}">{{.Author}}</a> · {{when .UpdatedAt}}</p> | |
| 29 | {{template "itemlist" dict "Items" .Issues "Kind" "issues" "Empty" "No open issues"}} | |
| 30 | ||
| 31 | </div> | |
| 32 | ||
| 33 | <aside class="aside"> | |
| 34 | <div class="grp"> | |
| 35 | <h2>Pinned</h2> | |
| 36 | {{range .Pinned}}<p class="row"><a href="/{{.OwnerName}}/{{.Name}}"><span class="owner">{{.OwnerName}}/</span>{{.Name}}</a>{{if eq .Visibility "private"}} <span class="chip">Private</span>{{end}}</p> | |
| 37 | {{else}}<p class="none">Pin a repository to keep it here</p>{{end}} | |
| 29 | 38 | </div> |
| 30 | </li> | |
| 31 | {{else}}<li class="empty">no open issues</li>{{end}} | |
| 32 | </ul> | |
| 39 | <div class="grp"> | |
| 40 | <h2>Recent activity</h2> | |
| 41 | {{range .Feed}}<p class="row feedline"><a href="/{{.Actor}}">{{.Actor}}</a> {{.Verb}} <a href="{{.URL}}">{{.Ref}}</a><br><span class="none">{{.Repo}} · {{when .When}}</span></p> | |
| 42 | {{else}}<p class="none">No activity yet</p>{{end}} | |
| 43 | </div> | |
| 44 | </aside> | |
| 45 | </div> | |
| 33 | 46 | {{end}} |