web, store, policy, wiki: Atom feeds for releases, commits, and an owner's activity !346
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 | ||
| 329 | The web shows them under the repository's =releases= tab with rendered | 329 | The web shows them under the repository's =releases= tab with rendered |
| 330 | notes, sha256 sums, and download links. | 330 | notes, 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 | ||
| 333 | activity on their public repositories at =/you/activity.atom=. The | ||
| 334 | pages carry the discovery link. | ||
| 331 | 335 | ||
| 332 | Commit messages act on issues when the commits land on the default | 336 | Commit messages act on issues when the commits land on the default |
| 333 | branch (direct push or MR merge): =closes/fixes/resolves #4= closes the | 337 | branch (direct push or MR merge): =closes/fixes/resolves #4= closes the |
e2e/feeds_test.go added +93
| @@ -0,0 +1,93 @@ | |||
| 1 | package e2e | ||
| 2 | |||
| 3 | import ( | ||
| 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). | ||
| 13 | func 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 @@ | |||
| 1 | package httpd | ||
| 2 | |||
| 3 | import ( | ||
| 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 | |||
| 22 | type 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 | |||
| 31 | type atomLink struct { | ||
| 32 | Rel string `xml:"rel,attr"` | ||
| 33 | Href string `xml:"href,attr"` | ||
| 34 | Type string `xml:"type,attr,omitempty"` | ||
| 35 | } | ||
| 36 | |||
| 37 | type 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 | |||
| 46 | type atomAuthor struct { | ||
| 47 | Name string `xml:"name"` | ||
| 48 | } | ||
| 49 | |||
| 50 | type atomContent struct { | ||
| 51 | Type string `xml:"type,attr"` | ||
| 52 | Text string `xml:",chardata"` | ||
| 53 | } | ||
| 54 | |||
| 55 | const atomLimit = 50 | ||
| 56 | |||
| 57 | func (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. | ||
| 61 | func (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 | |||
| 78 | func (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 | |||
| 108 | func (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. | ||
| 141 | func (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. | ||
| 172 | func 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 @@ | |||
| 1 | package httpd | ||
| 2 | |||
| 3 | import ( | ||
| 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 | |||
| 13 | func 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 | |||
| 52 | func 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 | ||
| 455 | func (s *Server) repoHome(w http.ResponseWriter, r *http.Request) { | 457 | func (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 | |||
| 4 | import ( | 4 | import ( |
| 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. | ||
| 300 | func (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"> |