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
102 inside the migration's own transaction, before commit, so a 102 inside the migration's own transaction, before commit, so a
103 violation rolls the migration back instead of leaving the bad 103 violation rolls the migration back instead of leaving the bad
104 schema and =user_version= already persisted (#261). 104 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).
105 110
106* v1.36.0 — 2026-09-23 111* v1.36.0 — 2026-09-23
107 112
internal/httpd/account_test.go +109
@@ -175,3 +175,112 @@ func TestAccountPageMasksAShortDeviceToken(t *testing.T) {
175 t.Fatalf("the device table has no id column:\n%s", body) 175 t.Fatalf("the device table has no id column:\n%s", body)
176 } 176 }
177} 177}
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)
240 http.Redirect(w, r, "/"+owner+"/"+name, http.StatusSeeOther) 240 http.Redirect(w, r, "/"+owner+"/"+name, http.StatusSeeOther)
241} 241}
242 242
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).
244func (s *Server) pinToggle(w http.ResponseWriter, r *http.Request, u store.User) { 246func (s *Server) pinToggle(w http.ResponseWriter, r *http.Request, u store.User) {
245 repo, ok := s.repoForUser(w, r, u, policy.CanRead) 247 repo, ok := s.repoForUser(w, r, u, policy.CanRead)
246 if !ok { 248 if !ok {
247 return 249 return
248 } 250 }
251 verb := "pin"
249 if s.st.IsPinned(u.ID, repo.ID) { 252 if s.st.IsPinned(u.ID, repo.ID) {
250 s.st.UnpinRepo(u.ID, repo.ID) 253 verb = "unpin"
251 } else { 254 }
252 s.st.PinRepo(u.ID, repo.ID) 255 if _, msg, ok := s.runControl(u, []string{"repo", verb, repo.Path()}); !ok {
256 s.setFlash(w, msg)
253 } 257 }
254 http.Redirect(w, r, "/"+repo.Path(), http.StatusSeeOther) 258 http.Redirect(w, r, "/"+repo.Path(), http.StatusSeeOther)
255} 259}
internal/httpd/notifyweb.go +7 −6
@@ -56,17 +56,18 @@ func (s *Server) notificationsRead(w http.ResponseWriter, r *http.Request, u sto
56 http.Redirect(w, r, "/notifications", http.StatusSeeOther) 56 http.Redirect(w, r, "/notifications", http.StatusSeeOther)
57} 57}
58 58
59// watchToggle turns watching a repository on and off from its header, 59// watchToggle cycles the viewer's watch state on a repository: default,
60// the way the pin button does. 60// watching, muted, back to default — through repo watch/repo mute/repo
61// unwatch, the same commands the CLI runs (#261, #271).
61func (s *Server) watchToggle(w http.ResponseWriter, r *http.Request, u store.User) { 62func (s *Server) watchToggle(w http.ResponseWriter, r *http.Request, u store.User) {
62 repo, ok := s.repoForUser(w, r, u, policy.CanRead) 63 repo, ok := s.repoForUser(w, r, u, policy.CanRead)
63 if !ok { 64 if !ok {
64 return 65 return
65 } 66 }
66 if s.st.RepoWatchState(repo.ID, u.ID) == "watching" { 67 next := map[string]string{"": "watch", "watching": "mute", "muted": "unwatch"}
67 s.st.ClearRepoWatch(repo.ID, u.ID) 68 verb := next[s.st.RepoWatchState(repo.ID, u.ID)]
68 } else { 69 if _, msg, ok := s.runControl(u, []string{"repo", verb, repo.Path()}); !ok {
69 s.st.SetRepoWatch(repo.ID, u.ID, "watching") 70 s.setFlash(w, msg)
70 } 71 }
71 http.Redirect(w, r, "/"+repo.Path(), http.StatusSeeOther) 72 http.Redirect(w, r, "/"+repo.Path(), http.StatusSeeOther)
72} 73}