web, store, policy, wiki: Atom feeds for releases, commits, and an owner's activity !346

merged merged by cmc on 2026-09-08 02:35 UTC · krz/gitbay:atom-feeds into main

11 files changed, +415 −3

Layout: unified · split

.gitbay/wiki/Parity.org +1
@@ -163,6 +163,7 @@ always markdown.
163| visibility | yes | yes | yes | 163| visibility | yes | yes | yes |
164| archive (read-only flag) | yes | yes | yes | 164| archive (read-only flag) | yes | yes | yes |
165| release list, show | yes | yes | yes | 165| release list, show | yes | yes | yes |
166| atom feeds | n/a | yes | n/a |
166| release create, edit | yes | yes | yes | 167| release create, edit | yes | yes | yes |
167| build list | yes | yes | yes | 168| build list | yes | yes | yes |
168| build show (one build) | yes | yes | yes | 169| build show (one build) | yes | yes | yes |
.gitbay/wiki/Users.org +5 −1
@@ -327,7 +327,11 @@ gitbay release list / show v1.0 / delete v1.0 --yes
327#+end_src 327#+end_src
328 328
329The web shows them under the repository's =releases= tab with rendered 329The web shows them under the repository's =releases= tab with rendered
330notes, sha256 sums, and download links. 330notes, sha256 sums, and download links. Feed readers subscribe at
331=/you/project/releases.atom=; commits are at =/you/project/log.atom=
332(the default branch) or =/you/project/log.atom/<ref>=, and an owner's
333activity on their public repositories at =/you/activity.atom=. The
334pages carry the discovery link.
331 335
332Commit messages act on issues when the commits land on the default 336Commit messages act on issues when the commits land on the default
333branch (direct push or MR merge): =closes/fixes/resolves #4= closes the 337branch (direct push or MR merge): =closes/fixes/resolves #4= closes the
e2e/feeds_test.go added +93
@@ -0,0 +1,93 @@
1package e2e
2
3import (
4 "net/http"
5 "os"
6 "path/filepath"
7 "strings"
8 "testing"
9)
10
11// Atom feeds for releases, commits and an owner's public activity, read
12// with no session; private repositories stay out of them (#192).
13func TestAtomFeeds(t *testing.T) {
14 inst := startInstance(t)
15 aliceKey := inst.newKey(t, "alice")
16 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub")
17 for _, args := range [][]string{
18 {"repo", "create", "alice/app"},
19 {"repo", "create", "alice/secret", "--private"},
20 } {
21 if _, errOut, code := inst.ssh(t, aliceKey, "", args...); code != 0 {
22 t.Fatalf("%v: %s", args, errOut)
23 }
24 }
25 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/feed.atom"); code == 0 || !strings.Contains(errOut, ".atom") {
26 t.Fatalf("a repository named like a feed was accepted: %d %s", code, errOut)
27 }
28 env := inst.gitEnv(aliceKey)
29 for _, name := range []string{"app", "secret"} {
30 dir := filepath.Join(t.TempDir(), name)
31 mustGit(t, t.TempDir(), env, "clone", "-q", inst.sshURL("alice/"+name), dir)
32 if err := os.WriteFile(filepath.Join(dir, "a.txt"), []byte("a\n"), 0o644); err != nil {
33 t.Fatal(err)
34 }
35 mustGit(t, dir, env, "checkout", "-q", "-b", "main")
36 mustGit(t, dir, env, "add", ".")
37 mustGit(t, dir, env, "commit", "-q", "-m", "first commit on "+name)
38 mustGit(t, dir, env, "tag", "v1.0")
39 mustGit(t, dir, env, "push", "-q", "origin", "main", "v1.0")
40 if _, errOut, code := inst.ssh(t, aliceKey, "", "release", "create", "alice/"+name, "v1.0",
41 "--title", "'First light'", "--notes", "'notes for "+name+"'"); code != 0 {
42 t.Fatalf("release create: %s", errOut)
43 }
44 }
45
46 anon := &http.Client{}
47 get := func(path string, want int) string {
48 t.Helper()
49 status, body := browserGet(t, anon, inst.base()+path)
50 if status != want {
51 t.Fatalf("GET %s: %d, want %d\n%s", path, status, want, body)
52 }
53 return body
54 }
55 resp, err := http.Get(inst.base() + "/alice/app/releases.atom")
56 if err != nil {
57 t.Fatal(err)
58 }
59 resp.Body.Close()
60 if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "application/atom+xml") {
61 t.Fatalf("content type %q", ct)
62 }
63 body := get("/alice/app/releases.atom", 200)
64 for _, want := range []string{`xmlns="http://www.w3.org/2005/Atom"`, "<title>v1.0: First light</title>", "notes for app", "/alice/app/releases#v1.0"} {
65 if !strings.Contains(body, want) {
66 t.Errorf("releases feed missing %s:\n%s", want, body)
67 }
68 }
69 for _, path := range []string{"/alice/app/log.atom", "/alice/app/log.atom/main"} {
70 if body := get(path, 200); !strings.Contains(body, "<title>first commit on app</title>") || !strings.Contains(body, "<name>t</name>") {
71 t.Errorf("%s:\n%s", path, body)
72 }
73 }
74 get("/alice/app/log.atom/nope", 404)
75 body = get("/alice/activity.atom", 200)
76 if !strings.Contains(body, "release.created alice/app v1.0") || strings.Contains(body, "secret") {
77 t.Errorf("activity feed:\n%s", body)
78 }
79 get("/nobody/activity.atom", 404)
80 get("/alice/secret/releases.atom", 404)
81 get("/alice/secret/log.atom", 404)
82
83 // The pages say where their feed is.
84 for path, feed := range map[string]string{
85 "/alice/app/releases": "/alice/app/releases.atom",
86 "/alice/app/log": "/alice/app/log.atom/main",
87 "/alice": "/alice/activity.atom",
88 } {
89 if body := get(path, 200); !strings.Contains(body, `type="application/atom+xml" href="`+feed+`"`) {
90 t.Errorf("%s has no discovery link to %s", path, feed)
91 }
92 }
93}
internal/httpd/atom.go added +195
@@ -0,0 +1,195 @@
1package httpd
2
3import (
4 "encoding/json"
5 "encoding/xml"
6 "fmt"
7 "net/http"
8 "strings"
9 "time"
10
11 "gitbay.org/gitbay/internal/gitutil"
12 "gitbay.org/gitbay/internal/sig"
13 "gitbay.org/gitbay/internal/store"
14)
15
16// Atom feeds (#192): a repository's releases and commits, and an owner's
17// public activity. Renderers over rows other surfaces already serve;
18// nothing is stored for them. A feed reader carries no session, so the
19// owner feed covers public repositories only, and a private repository
20// answers 404 as its pages do.
21
22type atomFeed struct {
23 XMLName xml.Name `xml:"http://www.w3.org/2005/Atom feed"`
24 ID string `xml:"id"`
25 Title string `xml:"title"`
26 Updated string `xml:"updated"`
27 Links []atomLink `xml:"link"`
28 Entries []atomEntry `xml:"entry"`
29}
30
31type atomLink struct {
32 Rel string `xml:"rel,attr"`
33 Href string `xml:"href,attr"`
34 Type string `xml:"type,attr,omitempty"`
35}
36
37type atomEntry struct {
38 ID string `xml:"id"`
39 Title string `xml:"title"`
40 Updated string `xml:"updated"`
41 Author *atomAuthor `xml:"author,omitempty"`
42 Link atomLink `xml:"link"`
43 Content *atomContent `xml:"content,omitempty"`
44}
45
46type atomAuthor struct {
47 Name string `xml:"name"`
48}
49
50type atomContent struct {
51 Type string `xml:"type,attr"`
52 Text string `xml:",chardata"`
53}
54
55const atomLimit = 50
56
57func (s *Server) site() string { return strings.TrimSuffix(s.cfg.Server.SiteURL, "/") }
58
59// writeAtom serialises the feed. Updated falls back to the newest entry,
60// then to now: a feed with no entries is still a feed.
61func (s *Server) writeAtom(w http.ResponseWriter, path, title string, entries []atomEntry) {
62 f := atomFeed{ID: s.site() + path, Title: title, Entries: entries,
63 Links: []atomLink{
64 {Rel: "self", Href: s.site() + path, Type: "application/atom+xml"},
65 {Rel: "alternate", Href: s.site() + strings.TrimSuffix(strings.TrimSuffix(path, ".atom"), "/activity")},
66 }}
67 if len(entries) > 0 {
68 f.Updated = entries[0].Updated
69 } else {
70 f.Updated = time.Now().UTC().Format(time.RFC3339)
71 }
72 w.Header().Set("Content-Type", "application/atom+xml; charset=utf-8")
73 w.Header().Set("X-Content-Type-Options", "nosniff")
74 fmt.Fprint(w, xml.Header)
75 xml.NewEncoder(w).Encode(f)
76}
77
78func (s *Server) releasesAtom(w http.ResponseWriter, r *http.Request) {
79 p, ok := s.repoFor(w, r, "")
80 if !ok {
81 return
82 }
83 rels, err := s.st.ListReleases(p.Repo.ID)
84 if err != nil {
85 http.Error(w, "internal error", http.StatusInternalServerError)
86 return
87 }
88 base := "/" + p.Repo.Path()
89 var entries []atomEntry
90 for _, rel := range rels {
91 title := rel.Tag
92 if rel.Title != "" {
93 title = rel.Tag + ": " + rel.Title
94 }
95 e := atomEntry{ID: s.site() + base + "/releases#" + rel.Tag, Title: title, Updated: rel.CreatedAt,
96 Link: atomLink{Rel: "alternate", Href: s.site() + base + "/releases#" + rel.Tag}}
97 if rel.Author != "" {
98 e.Author = &atomAuthor{Name: rel.Author}
99 }
100 if rel.Notes != "" {
101 e.Content = &atomContent{Type: "text", Text: rel.Notes}
102 }
103 entries = append(entries, e)
104 }
105 s.writeAtom(w, base+"/releases.atom", p.Repo.Path()+" releases", entries)
106}
107
108func (s *Server) logAtom(w http.ResponseWriter, r *http.Request) {
109 p, ok := s.repoFor(w, r, r.PathValue("ref"))
110 if !ok {
111 return
112 }
113 shas, err := gitutil.RevList(p.Dir, p.Ref, atomLimit)
114 if err != nil {
115 s.notFound(w, r)
116 return
117 }
118 base := "/" + p.Repo.Path()
119 var entries []atomEntry
120 for _, sha := range shas {
121 e := atomEntry{ID: s.site() + base + "/commit/" + sha, Title: sha[:10],
122 Link: atomLink{Rel: "alternate", Href: s.site() + base + "/commit/" + sha}}
123 if raw, err := gitutil.ReadCommit(p.Dir, sha); err == nil {
124 if c, err := sig.ParseCommit(raw); err == nil {
125 e.Title = c.Subject
126 e.Updated = time.Unix(c.AuthorUnix, 0).UTC().Format(time.RFC3339)
127 e.Author = &atomAuthor{Name: c.AuthorName}
128 }
129 }
130 entries = append(entries, e)
131 }
132 path := base + "/log.atom"
133 if r.PathValue("ref") != "" {
134 path += "/" + p.Ref
135 }
136 s.writeAtom(w, path, p.Repo.Path()+" commits on "+p.Ref, entries)
137}
138
139// ownerAtom is an owner's activity on their public repositories, the
140// feed command's rows without a viewer.
141func (s *Server) ownerAtom(w http.ResponseWriter, r *http.Request) {
142 name := r.PathValue("owner")
143 kind, id := "", int64(0)
144 if u, err := s.st.UserByUsername(name); err == nil {
145 kind, id = "user", u.ID
146 } else if o, err := s.st.OrgByName(name); err == nil {
147 kind, id = "org", o.ID
148 } else {
149 s.notFound(w, r)
150 return
151 }
152 events, err := s.st.OwnerPublicEvents(kind, id, atomLimit)
153 if err != nil {
154 http.Error(w, "internal error", http.StatusInternalServerError)
155 return
156 }
157 var entries []atomEntry
158 for _, ev := range events {
159 title, link := eventTitle(ev)
160 e := atomEntry{ID: fmt.Sprintf("%s/%s/activity.atom#%d", s.site(), name, ev.ID), Title: title, Updated: ev.CreatedAt,
161 Link: atomLink{Rel: "alternate", Href: s.site() + link}}
162 if ev.Actor != "" {
163 e.Author = &atomAuthor{Name: ev.Actor}
164 }
165 entries = append(entries, e)
166 }
167 s.writeAtom(w, "/"+name+"/activity.atom", name+" activity", entries)
168}
169
170// eventTitle names an event and where it points, from the kind and the
171// number or tag its data carries.
172func eventTitle(ev store.FeedEvent) (title, link string) {
173 var data struct {
174 Number int64 `json:"number"`
175 Tag string `json:"tag"`
176 }
177 json.Unmarshal([]byte(ev.Data), &data)
178 link = "/" + ev.RepoPath
179 title = ev.Kind + " in " + ev.RepoPath
180 switch {
181 case strings.HasPrefix(ev.Kind, "issue.") && data.Number > 0:
182 link += fmt.Sprintf("/issues/%d", data.Number)
183 title = fmt.Sprintf("%s %s#%d", ev.Kind, ev.RepoPath, data.Number)
184 case strings.HasPrefix(ev.Kind, "mr.") && data.Number > 0:
185 link += fmt.Sprintf("/mrs/%d", data.Number)
186 title = fmt.Sprintf("%s %s!%d", ev.Kind, ev.RepoPath, data.Number)
187 case strings.HasPrefix(ev.Kind, "release.") && data.Tag != "":
188 link += "/releases#" + data.Tag
189 title = fmt.Sprintf("%s %s %s", ev.Kind, ev.RepoPath, data.Tag)
190 }
191 if ev.Actor != "" {
192 title = ev.Actor + ": " + title
193 }
194 return title, link
195}
internal/httpd/atom_test.go added +72
@@ -0,0 +1,72 @@
1package httpd
2
3import (
4 "encoding/xml"
5 "net/http/httptest"
6 "strings"
7 "testing"
8
9 "gitbay.org/gitbay/internal/config"
10 "gitbay.org/gitbay/internal/store"
11)
12
13func TestWriteAtom(t *testing.T) {
14 s := &Server{cfg: config.Config{Server: config.Server{SiteURL: "https://forge.test/"}}}
15 rec := httptest.NewRecorder()
16 s.writeAtom(rec, "/alice/app/releases.atom", "alice/app releases", []atomEntry{
17 {ID: "https://forge.test/alice/app/releases#v1", Title: "v1: <first>", Updated: "2026-09-07T10:00:00Z",
18 Author: &atomAuthor{Name: "alice"}, Link: atomLink{Rel: "alternate", Href: "https://forge.test/alice/app/releases#v1"},
19 Content: &atomContent{Type: "text", Text: "notes & more"}},
20 })
21 if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/atom+xml") {
22 t.Fatalf("content type %q", ct)
23 }
24 var got atomFeed
25 if err := xml.Unmarshal(rec.Body.Bytes(), &got); err != nil {
26 t.Fatalf("not XML: %v\n%s", err, rec.Body.String())
27 }
28 if got.ID != "https://forge.test/alice/app/releases.atom" || got.Updated != "2026-09-07T10:00:00Z" || len(got.Entries) != 1 {
29 t.Fatalf("feed: %+v", got)
30 }
31 if e := got.Entries[0]; e.Title != "v1: <first>" || e.Content.Text != "notes & more" || e.Author.Name != "alice" {
32 t.Fatalf("entry: %+v", e)
33 }
34 alt := ""
35 for _, l := range got.Links {
36 if l.Rel == "alternate" {
37 alt = l.Href
38 }
39 }
40 if alt != "https://forge.test/alice/app/releases" {
41 t.Fatalf("alternate link %q", alt)
42 }
43
44 // No entries: still a feed, updated now.
45 rec = httptest.NewRecorder()
46 s.writeAtom(rec, "/alice/activity.atom", "alice activity", nil)
47 if err := xml.Unmarshal(rec.Body.Bytes(), &got); err != nil || got.Updated == "" {
48 t.Fatalf("empty feed: %v %+v", err, got)
49 }
50}
51
52func TestEventTitle(t *testing.T) {
53 cases := []struct {
54 ev store.FeedEvent
55 title, link string
56 }{
57 {store.FeedEvent{RepoPath: "alice/app", Actor: "bob", Kind: "issue.created", Data: `{"number":3}`},
58 "bob: issue.created alice/app#3", "/alice/app/issues/3"},
59 {store.FeedEvent{RepoPath: "alice/app", Actor: "bob", Kind: "mr.merged", Data: `{"number":7}`},
60 "bob: mr.merged alice/app!7", "/alice/app/mrs/7"},
61 {store.FeedEvent{RepoPath: "alice/app", Actor: "alice", Kind: "release.created", Data: `{"tag":"v1.0"}`},
62 "alice: release.created alice/app v1.0", "/alice/app/releases#v1.0"},
63 {store.FeedEvent{RepoPath: "alice/app", Kind: "repo.imported", Data: `{"from":"x"}`},
64 "repo.imported in alice/app", "/alice/app"},
65 }
66 for _, tc := range cases {
67 title, link := eventTitle(tc.ev)
68 if title != tc.title || link != tc.link {
69 t.Errorf("%s: got %q %q, want %q %q", tc.ev.Kind, title, link, tc.title, tc.link)
70 }
71 }
72}
internal/httpd/routes.go +4
@@ -67,6 +67,10 @@ func (s *Server) Routes() []Route {
67 Route{Method: "GET", Pattern: "/{owner}/{repo}/wiki/_raw/{path...}", Handler: s.wikiRaw}, 67 Route{Method: "GET", Pattern: "/{owner}/{repo}/wiki/_raw/{path...}", Handler: s.wikiRaw},
68 Route{Method: "GET", Pattern: "/{owner}/{repo}/wiki/{page}", Handler: s.wiki}, 68 Route{Method: "GET", Pattern: "/{owner}/{repo}/wiki/{page}", Handler: s.wiki},
69 Route{Method: "GET", Pattern: "/{owner}/{repo}/releases", Handler: s.releases}, 69 Route{Method: "GET", Pattern: "/{owner}/{repo}/releases", Handler: s.releases},
70 Route{Method: "GET", Pattern: "/{owner}/{repo}/releases.atom", Handler: s.releasesAtom},
71 Route{Method: "GET", Pattern: "/{owner}/{repo}/log.atom", Handler: s.logAtom},
72 Route{Method: "GET", Pattern: "/{owner}/{repo}/log.atom/{ref...}", Handler: s.logAtom},
73 Route{Method: "GET", Pattern: "/{owner}/activity.atom", Handler: s.ownerAtom},
70 Route{Method: "GET", Pattern: "/{owner}/{repo}/builds", Handler: s.builds}, 74 Route{Method: "GET", Pattern: "/{owner}/{repo}/builds", Handler: s.builds},
71 Route{Method: "GET", Pattern: "/{owner}/{repo}/badge/build.svg", Handler: s.buildBadge}, 75 Route{Method: "GET", Pattern: "/{owner}/{repo}/badge/build.svg", Handler: s.buildBadge},
72 Route{Method: "GET", Pattern: "/{owner}/{repo}/badge/build.png", Handler: s.buildBadgePNG}, 76 Route{Method: "GET", Pattern: "/{owner}/{repo}/badge/build.png", Handler: s.buildBadgePNG},
internal/httpd/web.go +5 −1
@@ -253,6 +253,7 @@ type repoPage struct {
253 Host string 253 Host string
254 Mirrors []mirrorLine // repo admins only 254 Mirrors []mirrorLine // repo admins only
255 CanAdmin bool // gates the settings tab 255 CanAdmin bool // gates the settings tab
256 Feed string // Atom feed for this page, if it has one
256 // OpenIssues and OpenMRs are the counts on the header tabs. 257 // OpenIssues and OpenMRs are the counts on the header tabs.
257 OpenIssues int 258 OpenIssues int
258 OpenMRs int 259 OpenMRs int
@@ -445,11 +446,12 @@ func (s *Server) ownerPage(w http.ResponseWriter, r *http.Request) {
445 CanAdmin bool 446 CanAdmin bool
446 Self bool 447 Self bool
447 Notice string 448 Notice string
449 Feed string
448 }{s.baseFor(viewer), name, d.Kind, profile, aboutHTML(profile), 450 }{s.baseFor(viewer), name, d.Kind, profile, aboutHTML(profile),
449 d.Repos, d.Members, d.Orgs, 451 d.Repos, d.Members, d.Orgs,
450 weeks, activityTotal, teams, canAdmin, 452 weeks, activityTotal, teams, canAdmin,
451 d.Kind == "user" && viewer.ID != 0 && strings.EqualFold(viewer.Username, name), 453 d.Kind == "user" && viewer.ID != 0 && strings.EqualFold(viewer.Username, name),
452 s.takeFlash(w, r)}) 454 s.takeFlash(w, r), "/" + name + "/activity.atom"})
453} 455}
454 456
455func (s *Server) repoHome(w http.ResponseWriter, r *http.Request) { 457func (s *Server) repoHome(w http.ResponseWriter, r *http.Request) {
@@ -613,6 +615,7 @@ func (s *Server) releases(w http.ResponseWriter, r *http.Request) {
613 return 615 return
614 } 616 }
615 p.Tab = "releases" 617 p.Tab = "releases"
618 p.Feed = "/" + p.Repo.Path() + "/releases.atom"
616 rels, err := s.st.ListReleases(p.Repo.ID) 619 rels, err := s.st.ListReleases(p.Repo.ID)
617 if err != nil { 620 if err != nil {
618 http.Error(w, "internal error", http.StatusInternalServerError) 621 http.Error(w, "internal error", http.StatusInternalServerError)
@@ -1406,6 +1409,7 @@ func (s *Server) log(w http.ResponseWriter, r *http.Request) {
1406 return 1409 return
1407 } 1410 }
1408 p.Tab = "log" 1411 p.Tab = "log"
1412 p.Feed = "/" + p.Repo.Path() + "/log.atom/" + p.Ref
1409 const pageSize = 50 1413 const pageSize = 50
1410 // ?path= filters to commits touching one file or directory. 1414 // ?path= filters to commits touching one file or directory.
1411 filePath := strings.Trim(path.Clean("/"+r.URL.Query().Get("path")), "/") 1415 filePath := strings.Trim(path.Clean("/"+r.URL.Query().Get("path")), "/")
internal/policy/names.go +6
@@ -4,6 +4,7 @@ package policy
4import ( 4import (
5 "fmt" 5 "fmt"
6 "regexp" 6 "regexp"
7 "strings"
7) 8)
8 9
9// reservedNames are forbidden as usernames and org names because they are, or 10// reservedNames are forbidden as usernames and org names because they are, or
@@ -60,6 +61,11 @@ func ValidateName(name string) error {
60 if len(name) > 4 && name[len(name)-4:] == ".git" { 61 if len(name) > 4 && name[len(name)-4:] == ".git" {
61 return fmt.Errorf("invalid name %q: must not end in .git", name) 62 return fmt.Errorf("invalid name %q: must not end in .git", name)
62 } 63 }
64 // /{owner}/activity.atom is the owner's feed; a repository by that
65 // name would be unreachable.
66 if strings.HasSuffix(name, ".atom") {
67 return fmt.Errorf("invalid name %q: must not end in .atom", name)
68 }
63 return nil 69 return nil
64} 70}
65 71
internal/policy/names_test.go +3
@@ -39,4 +39,7 @@ func TestRepoNameAllowsReservedWords(t *testing.T) {
39 if err := ValidateName("repo.git"); err == nil { 39 if err := ValidateName("repo.git"); err == nil {
40 t.Error("ValidateName(\"repo.git\") = nil, want error") 40 t.Error("ValidateName(\"repo.git\") = nil, want error")
41 } 41 }
42 if err := ValidateName("activity.atom"); err == nil {
43 t.Error("ValidateName(\"activity.atom\") = nil, want error")
44 }
42} 45}
internal/store/dashboard.go +29
@@ -294,6 +294,35 @@ type FeedEvent struct {
294 CreatedAt string 294 CreatedAt string
295} 295}
296 296
297// OwnerPublicEvents returns activity on an owner's public repositories,
298// newest first, for readers carrying no session. Push events are
299// excluded as in RecentEvents.
300func (s *Store) OwnerPublicEvents(ownerKind string, ownerID int64, limit int) ([]FeedEvent, error) {
301 rows, err := s.DB.Query(`
302 SELECT e.id, COALESCE(u.username, o.name) || '/' || r.name,
303 COALESCE(ac.username, ''), e.kind, e.data_json, e.created_at
304 FROM events e
305 JOIN repos r ON r.id = e.repo_id
306 LEFT JOIN users u ON r.owner_kind = 'user' AND u.id = r.owner_id
307 LEFT JOIN orgs o ON r.owner_kind = 'org' AND o.id = r.owner_id
308 LEFT JOIN users ac ON ac.id = e.actor_id
309 WHERE e.kind <> 'push' AND r.visibility = 'public' AND r.owner_kind = ? AND r.owner_id = ?
310 ORDER BY e.id DESC LIMIT ?`, ownerKind, ownerID, limit)
311 if err != nil {
312 return nil, err
313 }
314 defer rows.Close()
315 var out []FeedEvent
316 for rows.Next() {
317 var e FeedEvent
318 if err := rows.Scan(&e.ID, &e.RepoPath, &e.Actor, &e.Kind, &e.Data, &e.CreatedAt); err != nil {
319 return nil, err
320 }
321 out = append(out, e)
322 }
323 return out, rows.Err()
324}
325
297// RecentEvents returns activity on repositories the user can reach. Push 326// RecentEvents returns activity on repositories the user can reach. Push
298// events are excluded: they repeat what the commit lists already show. 327// events are excluded: they repeat what the commit lists already show.
299// before (an event id) starts the page strictly below it, matching the 328// before (an event id) starts the page strictly below it, matching the
internal/web/templates/layout.html +2 −1
@@ -6,7 +6,8 @@
6<title>{{template "title" .}}</title> 6<title>{{template "title" .}}</title>
7<link rel="stylesheet" href="/static/style.css"> 7<link rel="stylesheet" href="/static/style.css">
8<link rel="icon" href="/favicon.svg" type="image/svg+xml"> 8<link rel="icon" href="/favicon.svg" type="image/svg+xml">
9</head> 9{{with field . "Feed"}}<link rel="alternate" type="application/atom+xml" href="{{.}}">
10{{end}}</head>
10<body> 11<body>
11<a class="skip" href="#content">Skip to content</a> 12<a class="skip" href="#content">Skip to content</a>
12<div class="shell"> 13<div class="shell">