forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
6.3 KiB

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 authorize using their collaborator role):
    1. users subscribed to I (issue_subscriptions.state = 'subscribed'), with their stored reason;
    2. users watching R with level watching, unless they have an ignored subscription on I;
    3. users @mentioned in the new text who can read R. This auto-subscribes them with reason mention.
  • 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 ignored row 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 (with state), assigned (to that user: reason assign);
    • 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_repo must delete these rows for the repo and its issues.
  • delete_user (store/user_delete.rs) must delete the user's rows. In notifications.last_actor_id it 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 @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.
  • 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 reason assign even 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.
  • 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 shell in views/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_read for the viewer.