Tahinli/forge
public0 watching
★ 0
Notifications (contract between workers)
The global rules from the earlier interface docs apply:
- No JS and no inline styles; no unwrap in non-test code.
- Tests use
tempfile::TempDir. - Never put cargo target dirs or big files in /tmp.
Model
- One notification thread per (user, issue-or-PR). New activity on a thread upserts its row:
unread = 1,updated_at = now,reason= the strongest current reason,last_actor_id.- Reason strength, highest first:
mention>assign>review_requested>author>comment>state_change>subscribed>watching.
- Who gets notified about activity on issue I in repo R (never the actor themself; only users who can Read R now, checked with
authorizeusing their collaborator role):- users subscribed to I (
issue_subscriptions.state = 'subscribed'), with their stored reason; - users watching R with level
watching, unless they have anignoredsubscription on I; - users @mentioned in the new text who can read R. This auto-subscribes them with reason
mention.
- users subscribed to I (
- Auto-subscribe (state subscribed, if no row exists yet):
- the author on create;
- a commenter on comment;
- an assignee on assign;
- a reviewer on review submit;
- anyone mentioned.
An explicit
ignoredrow is never overwritten by auto-subscribe.
- Repo watch levels:
watching: all activity;participating: the default when no row exists; subscriptions only;ignoring: no notifications from this repo at all, even when subscribed. Mentions still notify.
- Activities:
opened,comment,closed,reopened,merged;review(withstate),assigned(to that user: reasonassign);mentioned,pushed(synchronize on a PR: subscribers only, not watchers).
Schema (migration 0012_notifications.sql)
CREATE TABLE repo_watches (user_id TEXT NOT NULL REFERENCES users(id), repo_id TEXT NOT NULL REFERENCES repos(id),
level TEXT NOT NULL, created_at TEXT NOT NULL, PRIMARY KEY (user_id, repo_id));
CREATE TABLE issue_subscriptions (user_id TEXT NOT NULL REFERENCES users(id), issue_id TEXT NOT NULL REFERENCES issues(id),
state TEXT NOT NULL, reason TEXT NOT NULL, created_at TEXT NOT NULL, PRIMARY KEY (user_id, issue_id));
CREATE TABLE notifications (id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id),
repo_id TEXT NOT NULL REFERENCES repos(id), issue_id TEXT NOT NULL REFERENCES issues(id),
reason TEXT NOT NULL, last_activity TEXT NOT NULL, last_actor_id TEXT REFERENCES users(id),
unread INTEGER NOT NULL DEFAULT 1, updated_at TEXT NOT NULL, UNIQUE (user_id, issue_id));
CREATE INDEX notifications_inbox ON notifications(user_id, unread, updated_at);
Store::delete_repomust delete these rows for the repo and its issues.delete_user(store/user_delete.rs) must delete the user's rows. Innotifications.last_actor_idit reassigns the user to ghost.
forge-core (src/notifications.rs model plus store/notifications.rs)
pub enum WatchLevel { Watching, Participating, Ignoring }
pub enum SubState { Subscribed, Ignored }
pub enum Reason { Mention, Assign, ReviewRequested, Author, Comment, StateChange, Subscribed, Watching } // as_str/parse, rank()
pub struct Notification { pub id: Id, pub repo_id: Id, pub repo_owner: String, pub repo_name: String, pub issue_id: Id,
pub number: i64, pub is_pull: bool, pub title: String, pub issue_state: String, pub reason: Reason,
pub last_activity: String, pub last_actor_name: Option<String>, pub unread: bool, pub updated_at: String }
set_watch(user, repo, WatchLevel); watch_level(user, repo) -> WatchLevel; watchers(repo) -> Vec<Id> // level watching
subscribe(user, issue, SubState, Reason); auto_subscribe(user, issue, Reason) // no-op if a row exists
subscription(user, issue) -> Option<(SubState, Reason)>; subscribers(issue) -> Vec<(Id, Reason)> // state subscribed
upsert_notification(user, repo, issue, reason, activity, actor) // keeps the stronger reason if one exists and the row is still unread
list_notifications(user, unread_only: bool, before: Option<&str>, limit) -> Vec<Notification>
unread_count(user) -> u64
mark_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
forge-web
crate::notify::dispatch(state, repo, issue, actor_id, activity, text_for_mentions: Option<&str>):- computes recipients per the rules above, then auto-subscribes and upserts;
- never fails the caller (it logs errors);
- runs inline (DB only, cheap).
- Mentions: extract
@usernamewith the same rules as the markdown@userlinking (reuse that code: greprender/markdown.rsfor the mention handling and factor apub fn mentioned_usernames(text) -> Vec<String>). Skip code spans and code blocks, and dedupe. - Call sites, the same places as the webhook
emit_*calls:- issue/PR open: activity
opened, with the body checked for mentions; - comment create:
comment, with mentions; - close and reopen (including closing keywords and merges):
closed/reopened/merged; - assign:
assigned, notifying the assignee with reasonassigneven when they don't watch; - review submit:
review, with the body and the review comment bodies checked for mentions; - PR head update:
pushed; - edit of a body or comment: mentions newly added by the edit (old vs new text) only.
- issue/PR open: activity
- Views:
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.- Clicking a row goes to
/notifications/{id}/open, which marks it read and redirects 303 to the issue or PR URL. - Rows for repos the user can no longer read are hidden, and marked read lazily.
- A header bell link with the unread count, for signed-in users (the
shellinviews/mod.rs); one cheap COUNT query. - Issue and PR sidebar: a "Notifications" box with Subscribe/Unsubscribe (POST, CSRF) and the reason ("You're receiving notifications because you were mentioned").
- Repo header: a Watch form with a
<select>(Participating / Watching / Ignoring) and a submit button (POST, CSRF); shows the watcher count. - Viewing an issue or PR page calls
mark_issue_readfor the viewer.