forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
6.3 KiB
1# Notifications (contract between workers)
2
3The global rules from the earlier interface docs apply:
4- No JS and no inline styles; no unwrap in non-test code.
5- Tests use `tempfile::TempDir`.
6- Never put cargo target dirs or big files in /tmp.
7
8## Model
9- **One notification thread per (user, issue-or-PR).** New activity on a thread upserts its row:
10 - `unread = 1`, `updated_at = now`, `reason` = the strongest current reason, `last_actor_id`.
11 - Reason strength, highest first: `mention` > `assign` > `review_requested` > `author` > `comment` > `state_change` > `subscribed` > `watching`.
12- **Who gets notified** about activity on issue I in repo R (never the actor themself; only users who can Read R *now*, checked with `authorize` using their collaborator role):
13 1. users subscribed to I (`issue_subscriptions.state = 'subscribed'`), with their stored reason;
14 2. users watching R with level `watching`, unless they have an `ignored` subscription on I;
15 3. users @mentioned in the new text who can read R. This auto-subscribes them with reason `mention`.
16- **Auto-subscribe** (state subscribed, if no row exists yet):
17 - the author on create;
18 - a commenter on comment;
19 - an assignee on assign;
20 - a reviewer on review submit;
21 - anyone mentioned.
22 An explicit `ignored` row is never overwritten by auto-subscribe.
23- **Repo watch levels:**
24 - `watching`: all activity;
25 - `participating`: the default when no row exists; subscriptions only;
26 - `ignoring`: no notifications from this repo at all, even when subscribed. **Mentions still notify.**
27- **Activities:**
28 - `opened`, `comment`, `closed`, `reopened`, `merged`;
29 - `review` (with `state`), `assigned` (to that user: reason `assign`);
30 - `mentioned`, `pushed` (synchronize on a PR: subscribers only, not watchers).
31
32## Schema (migration `0012_notifications.sql`)
33```sql
34CREATE TABLE repo_watches (user_id TEXT NOT NULL REFERENCES users(id), repo_id TEXT NOT NULL REFERENCES repos(id),
35 level TEXT NOT NULL, created_at TEXT NOT NULL, PRIMARY KEY (user_id, repo_id));
36CREATE TABLE issue_subscriptions (user_id TEXT NOT NULL REFERENCES users(id), issue_id TEXT NOT NULL REFERENCES issues(id),
37 state TEXT NOT NULL, reason TEXT NOT NULL, created_at TEXT NOT NULL, PRIMARY KEY (user_id, issue_id));
38CREATE TABLE notifications (id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id),
39 repo_id TEXT NOT NULL REFERENCES repos(id), issue_id TEXT NOT NULL REFERENCES issues(id),
40 reason TEXT NOT NULL, last_activity TEXT NOT NULL, last_actor_id TEXT REFERENCES users(id),
41 unread INTEGER NOT NULL DEFAULT 1, updated_at TEXT NOT NULL, UNIQUE (user_id, issue_id));
42CREATE INDEX notifications_inbox ON notifications(user_id, unread, updated_at);
43```
44- `Store::delete_repo` must delete these rows for the repo and its issues.
45- `delete_user` (store/user_delete.rs) must delete the user's rows. In `notifications.last_actor_id` it reassigns the user to ghost.
46
47## forge-core (`src/notifications.rs` model plus `store/notifications.rs`)
48```rust
49pub enum WatchLevel { Watching, Participating, Ignoring }
50pub enum SubState { Subscribed, Ignored }
51pub enum Reason { Mention, Assign, ReviewRequested, Author, Comment, StateChange, Subscribed, Watching } // as_str/parse, rank()
52pub struct Notification { pub id: Id, pub repo_id: Id, pub repo_owner: String, pub repo_name: String, pub issue_id: Id,
53 pub number: i64, pub is_pull: bool, pub title: String, pub issue_state: String, pub reason: Reason,
54 pub last_activity: String, pub last_actor_name: Option<String>, pub unread: bool, pub updated_at: String }
55
56set_watch(user, repo, WatchLevel); watch_level(user, repo) -> WatchLevel; watchers(repo) -> Vec<Id> // level watching
57subscribe(user, issue, SubState, Reason); auto_subscribe(user, issue, Reason) // no-op if a row exists
58subscription(user, issue) -> Option<(SubState, Reason)>; subscribers(issue) -> Vec<(Id, Reason)> // state subscribed
59upsert_notification(user, repo, issue, reason, activity, actor) // keeps the stronger reason if one exists and the row is still unread
60list_notifications(user, unread_only: bool, before: Option<&str>, limit) -> Vec<Notification>
61unread_count(user) -> u64
62mark_read(user, notification_id); mark_all_read(user, repo: Option<&str>); mark_issue_read(user, issue) // the last one is called when the user views the issue or PR
63```
64
65## forge-web
66- `crate::notify::dispatch(state, repo, issue, actor_id, activity, text_for_mentions: Option<&str>)`:
67 - computes recipients per the rules above, then auto-subscribes and upserts;
68 - never fails the caller (it logs errors);
69 - runs **inline** (DB only, cheap).
70- **Mentions:** extract `@username` with the same rules as the markdown `@user` linking (reuse that code: grep `render/markdown.rs` for the mention handling and factor a `pub fn mentioned_usernames(text) -> Vec<String>`). Skip code spans and code blocks, and dedupe.
71- **Call sites**, the same places as the webhook `emit_*` calls:
72 - issue/PR open: activity `opened`, with the body checked for mentions;
73 - comment create: `comment`, with mentions;
74 - close and reopen (including closing keywords and merges): `closed`/`reopened`/`merged`;
75 - assign: `assigned`, notifying the assignee with reason `assign` even when they don't watch;
76 - review submit: `review`, with the body and the review comment bodies checked for mentions;
77 - PR head update: `pushed`;
78 - edit of a body or comment: mentions newly added by the edit (old vs new text) only.
79- **Views:**
80 - `GET /notifications`: tabs Unread and All, grouped by repo; each row shows an icon (issue or PR, by state), `owner/name #N title`, reason, last activity, relative time and a "Mark read" button. Bulk actions: "Mark all read" per repo and globally. Cursor pagination, 50 per page.
81 - Clicking a row goes to `/notifications/{id}/open`, which marks it read and redirects 303 to the issue or PR URL.
82 - Rows for repos the user can no longer read are hidden, and marked read lazily.
83 - A header bell link with the unread count, for signed-in users (the `shell` in `views/mod.rs`); one cheap COUNT query.
84 - Issue and PR sidebar: a "Notifications" box with Subscribe/Unsubscribe (POST, CSRF) and the reason ("You're receiving notifications because you were mentioned").
85 - Repo header: a Watch form with a `<select>` (Participating / Watching / Ignoring) and a submit button (POST, CSRF); shows the watcher count.
86 - Viewing an issue or PR page calls `mark_issue_read` for the viewer.