Commit 98e6424c98

98e6424c9886ae6160a6e939eea39ffd87d5cc75

parent: aab3eb12e3

Verified · cmc ci/build: success ci/test: success

cmc <hello@cleberg.net> · 2026-09-07 22:56 UTC

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

Nothing served application/atom+xml. /{owner}/{repo}/releases.atom,
/{owner}/{repo}/log.atom[/{ref}] and /{owner}/activity.atom now do,
rendered over the rows the pages already serve: releases with their
notes, the last fifty commits on a ref, and an owner's activity on
their public repositories. A feed reader carries no session, so the
owner feed is public repositories only and a private repository
answers 404 as its pages do. The releases, log and owner pages carry
the discovery link. A repository name ending in .atom is refused, as
.git is, so the owner feed cannot shadow one. Parity row, Users lines.

Closes #192
.gitbay/wiki/Parity.org +1
@@ -163,6 +163,7 @@ always markdown.
163163| visibility | yes | yes | yes |
164164| archive (read-only flag) | yes | yes | yes |
165165| release list, show | yes | yes | yes |
166| atom feeds | n/a | yes | n/a |
166167| release create, edit | yes | yes | yes |
167168| build list | yes | yes | yes |
168169| 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
327327#+end_src
328328
329329The 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.
331335
332336Commit messages act on issues when the commits land on the default
333337branch (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 {
6767 Route{Method: "GET", Pattern: "/{owner}/{repo}/wiki/_raw/{path...}", Handler: s.wikiRaw},
6868 Route{Method: "GET", Pattern: "/{owner}/{repo}/wiki/{page}", Handler: s.wiki},
6969 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},
7074 Route{Method: "GET", Pattern: "/{owner}/{repo}/builds", Handler: s.builds},
7175 Route{Method: "GET", Pattern: "/{owner}/{repo}/badge/build.svg", Handler: s.buildBadge},
7276 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 {
253253 Host string
254254 Mirrors []mirrorLine // repo admins only
255255 CanAdmin bool // gates the settings tab
256 Feed string // Atom feed for this page, if it has one
256257 // OpenIssues and OpenMRs are the counts on the header tabs.
257258 OpenIssues int
258259 OpenMRs int
@@ -445,11 +446,12 @@ func (s *Server) ownerPage(w http.ResponseWriter, r *http.Request) {
445446 CanAdmin bool
446447 Self bool
447448 Notice string
449 Feed string
448450 }{s.baseFor(viewer), name, d.Kind, profile, aboutHTML(profile),
449451 d.Repos, d.Members, d.Orgs,
450452 weeks, activityTotal, teams, canAdmin,
451453 d.Kind == "user" && viewer.ID != 0 && strings.EqualFold(viewer.Username, name),
452 s.takeFlash(w, r)})
454 s.takeFlash(w, r), "/" + name + "/activity.atom"})
453455}
454456
455457func (s *Server) repoHome(w http.ResponseWriter, r *http.Request) {
@@ -613,6 +615,7 @@ func (s *Server) releases(w http.ResponseWriter, r *http.Request) {
613615 return
614616 }
615617 p.Tab = "releases"
618 p.Feed = "/" + p.Repo.Path() + "/releases.atom"
616619 rels, err := s.st.ListReleases(p.Repo.ID)
617620 if err != nil {
618621 http.Error(w, "internal error", http.StatusInternalServerError)
@@ -1406,6 +1409,7 @@ func (s *Server) log(w http.ResponseWriter, r *http.Request) {
14061409 return
14071410 }
14081411 p.Tab = "log"
1412 p.Feed = "/" + p.Repo.Path() + "/log.atom/" + p.Ref
14091413 const pageSize = 50
14101414 // ?path= filters to commits touching one file or directory.
14111415 filePath := strings.Trim(path.Clean("/"+r.URL.Query().Get("path")), "/")
internal/policy/names.go +6
@@ -4,6 +4,7 @@ package policy
44import (
55 "fmt"
66 "regexp"
7 "strings"
78)
89
910// reservedNames are forbidden as usernames and org names because they are, or
@@ -60,6 +61,11 @@ func ValidateName(name string) error {
6061 if len(name) > 4 && name[len(name)-4:] == ".git" {
6162 return fmt.Errorf("invalid name %q: must not end in .git", name)
6263 }
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 }
6369 return nil
6470}
6571
internal/policy/names_test.go +3
@@ -39,4 +39,7 @@ func TestRepoNameAllowsReservedWords(t *testing.T) {
3939 if err := ValidateName("repo.git"); err == nil {
4040 t.Error("ValidateName(\"repo.git\") = nil, want error")
4141 }
42 if err := ValidateName("activity.atom"); err == nil {
43 t.Error("ValidateName(\"activity.atom\") = nil, want error")
44 }
4245}
internal/store/dashboard.go +29
@@ -294,6 +294,35 @@ type FeedEvent struct {
294294 CreatedAt string
295295}
296296
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
297326// RecentEvents returns activity on repositories the user can reach. Push
298327// events are excluded: they repeat what the commit lists already show.
299328// before (an event id) starts the page strictly below it, matching the
internal/web/templates/layout.html +2 −1
@@ -6,7 +6,8 @@
66<title>{{template "title" .}}</title>
77<link rel="stylesheet" href="/static/style.css">
88<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>
1011<body>
1112<a class="skip" href="#content">Skip to content</a>
1213<div class="shell">