Commit d0e26d3df9

d0e26d3df9e7a00e003b8120afb9c27fed38e48b

parent: 0338e6ace3

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-28 08:51 UTC

web: pin and watch toggles dispatch through repo pin/watch/mute/unwatch

Ref #261

Layout: unified · split

CHANGELOG.org +5
@@ -102,6 +102,11 @@ for the eighteen commands whose CLI path differs from the registry's
102102 inside the migration's own transaction, before commit, so a
103103 violation rolls the migration back instead of leaving the bad
104104 schema and =user_version= already persisted (#261).
105- The web pin and watch buttons dispatch through =repo pin=/=unpin=
106 and =repo watch=/=mute=/=unwatch= instead of writing the store
107 directly, so a refusal reaches the viewer as a message instead of
108 being dropped. The watch button now cycles three states — default,
109 watching, muted — instead of two (#261).
105110
106111* v1.36.0 — 2026-09-23
107112
internal/httpd/account_test.go +109
@@ -175,3 +175,112 @@ func TestAccountPageMasksAShortDeviceToken(t *testing.T) {
175175 t.Fatalf("the device table has no id column:\n%s", body)
176176 }
177177}
178
179// assertAudited fails the test unless an audit row with the given action
180// prefix exists — proof a handler dispatched through the control
181// registry rather than writing the store directly, since only Dispatch
182// itself calls Store.Audit.
183func assertAudited(t *testing.T, st *store.Store, prefix string) {
184 t.Helper()
185 entries, err := st.AuditEntries(store.AuditFilter{ActionPrefix: prefix, Limit: 10})
186 if err != nil {
187 t.Fatal(err)
188 }
189 if len(entries) == 0 {
190 t.Fatalf("no audit row with action prefix %q", prefix)
191 }
192}
193
194// Pinning writes through the repo pin command, not the store directly,
195// so it carries the same audit trail and write budget as every other
196// mutating command (#261).
197func TestPinToggleDispatchesRepoPin(t *testing.T) {
198 st, err := store.Open(":memory:")
199 if err != nil {
200 t.Fatal(err)
201 }
202 defer st.Close()
203 if err := st.MigrateUp(); err != nil {
204 t.Fatal(err)
205 }
206 uid, err := st.CreateUser("alice", false)
207 if err != nil {
208 t.Fatal(err)
209 }
210 u := store.User{ID: uid, Username: "alice"}
211 if _, err := st.CreateRepo("user", uid, "app", "public"); err != nil {
212 t.Fatal(err)
213 }
214
215 s := New(config.Default(), st)
216 req := httptest.NewRequest("POST", "/alice/app/pin", nil)
217 req.SetPathValue("owner", "alice")
218 req.SetPathValue("repo", "app")
219 rr := httptest.NewRecorder()
220 s.pinToggle(rr, req, u)
221
222 repo, err := st.RepoByPath("alice/app")
223 if err != nil {
224 t.Fatal(err)
225 }
226 if !st.IsPinned(uid, repo.ID) {
227 t.Fatal("pin did not take effect")
228 }
229 assertAudited(t, st, "cmd repo pin")
230
231 rr2 := httptest.NewRecorder()
232 s.pinToggle(rr2, req, u)
233 if st.IsPinned(uid, repo.ID) {
234 t.Fatal("second toggle should have unpinned")
235 }
236 assertAudited(t, st, "cmd repo unpin")
237}
238
239// The watch button cycles default, watching, muted — the three states
240// repo watch/repo mute/repo unwatch already support — rather than the
241// two the store-writing version offered (#261, #271).
242func TestWatchToggleCyclesThroughMuted(t *testing.T) {
243 st, err := store.Open(":memory:")
244 if err != nil {
245 t.Fatal(err)
246 }
247 defer st.Close()
248 if err := st.MigrateUp(); err != nil {
249 t.Fatal(err)
250 }
251 uid, err := st.CreateUser("alice", false)
252 if err != nil {
253 t.Fatal(err)
254 }
255 u := store.User{ID: uid, Username: "alice"}
256 if _, err := st.CreateRepo("user", uid, "app", "public"); err != nil {
257 t.Fatal(err)
258 }
259 repo, err := st.RepoByPath("alice/app")
260 if err != nil {
261 t.Fatal(err)
262 }
263
264 s := New(config.Default(), st)
265 req := httptest.NewRequest("POST", "/alice/app/watch", nil)
266 req.SetPathValue("owner", "alice")
267 req.SetPathValue("repo", "app")
268
269 click := func() string {
270 rr := httptest.NewRecorder()
271 s.watchToggle(rr, req, u)
272 return st.RepoWatchState(repo.ID, uid)
273 }
274 if got := click(); got != "watching" {
275 t.Fatalf("first click: got %q, want watching", got)
276 }
277 assertAudited(t, st, "cmd repo watch")
278 if got := click(); got != "muted" {
279 t.Fatalf("second click: got %q, want muted", got)
280 }
281 assertAudited(t, st, "cmd repo mute")
282 if got := click(); got != "" {
283 t.Fatalf("third click: got %q, want default (unwatched)", got)
284 }
285 assertAudited(t, st, "cmd repo unwatch")
286}
internal/httpd/accounts.go +8 −4
@@ -240,16 +240,20 @@ func (s *Server) newSubmit(w http.ResponseWriter, r *http.Request, u store.User)
240240 http.Redirect(w, r, "/"+owner+"/"+name, http.StatusSeeOther)
241241}
242242
243// pinToggle pins or unpins the repo for the logged-in viewer.
243// pinToggle pins or unpins the repo for the logged-in viewer, through
244// repo pin/repo unpin — the same commands the CLI runs — rather than
245// writing the store directly (#261).
244246func (s *Server) pinToggle(w http.ResponseWriter, r *http.Request, u store.User) {
245247 repo, ok := s.repoForUser(w, r, u, policy.CanRead)
246248 if !ok {
247249 return
248250 }
251 verb := "pin"
249252 if s.st.IsPinned(u.ID, repo.ID) {
250 s.st.UnpinRepo(u.ID, repo.ID)
251 } else {
252 s.st.PinRepo(u.ID, repo.ID)
253 verb = "unpin"
254 }
255 if _, msg, ok := s.runControl(u, []string{"repo", verb, repo.Path()}); !ok {
256 s.setFlash(w, msg)
253257 }
254258 http.Redirect(w, r, "/"+repo.Path(), http.StatusSeeOther)
255259}
internal/httpd/notifyweb.go +7 −6
@@ -56,17 +56,18 @@ func (s *Server) notificationsRead(w http.ResponseWriter, r *http.Request, u sto
5656 http.Redirect(w, r, "/notifications", http.StatusSeeOther)
5757}
5858
59// watchToggle turns watching a repository on and off from its header,
60// the way the pin button does.
59// watchToggle cycles the viewer's watch state on a repository: default,
60// watching, muted, back to default — through repo watch/repo mute/repo
61// unwatch, the same commands the CLI runs (#261, #271).
6162func (s *Server) watchToggle(w http.ResponseWriter, r *http.Request, u store.User) {
6263 repo, ok := s.repoForUser(w, r, u, policy.CanRead)
6364 if !ok {
6465 return
6566 }
66 if s.st.RepoWatchState(repo.ID, u.ID) == "watching" {
67 s.st.ClearRepoWatch(repo.ID, u.ID)
68 } else {
69 s.st.SetRepoWatch(repo.ID, u.ID, "watching")
67 next := map[string]string{"": "watch", "watching": "mute", "muted": "unwatch"}
68 verb := next[s.st.RepoWatchState(repo.ID, u.ID)]
69 if _, msg, ok := s.runControl(u, []string{"repo", verb, repo.Path()}); !ok {
70 s.setFlash(w, msg)
7071 }
7172 http.Redirect(w, r, "/"+repo.Path(), http.StatusSeeOther)
7273}