patx/gitman

Add mention notifications and exclude personal activity from the feed

Commit 09a3dd1 · patx · 2026-11-03T12:54:21-05:00

Changeset
09a3dd1790b6f988a744e32a1e171c0b2bee26bc
Parents
7e8f9c8af25a750336b4466e21f0c8857172a2d9

View source at this commit

Comments

No comments yet.

Log in to comment

Diff

diff --git a/README.md b/README.md
index 7047713..4f63f3a 100644
--- a/README.md
+++ b/README.md
@@ -36,8 +36,19 @@ Pages-style static hosting is driven by the Git repository contents. A user site
 The bell beside the account and new repository icons opens your personal notifications
 feed. It includes activity on issues you created or commented on, pull requests in
 repositories you own, and activity (including commits and pull requests) in repositories
-you have starred. The feed loads older entries as you scroll. Unstarring a repository
-removes that source unless you also follow its issues or own its pull request target.
+you have starred. Your own actions are excluded. Commit authors matching your
+username or display name are treated as your own commits. The feed loads older
+entries as you scroll.
+
+Use `@username` in issue and pull request titles/descriptions or in issue, pull
+request, and commit comments to mention another account. Existing handles link
+to profiles, and the mentioned account sees the event in their feed even without
+starring the repository or participating in the thread. Mentions are case-insensitive;
+code, escaped handles, email addresses, and Markdown links do not trigger them.
+Repeated mentions and overlapping subscriptions yield one event. Mention matching
+also applies to existing discussions. Unstarring a repository
+removes that source unless you follow its issues, own its pull request target, or
+are mentioned in the event.
 Issue close/reopen and pull request close history is recorded from this version onward.
 Commit activity scans up to `GITMAN_ACTIVITY_REPO_SCAN_LIMIT` starred repositories,
 prioritizing the most recently updated repositories.
diff --git a/app.py b/app.py
index 7d94337..72c3a83 100644
--- a/app.py
+++ b/app.py
@@ -24,6 +24,7 @@ from urllib.parse import parse_qsl, quote, unquote, urlencode, urlparse
 from wsgiref.simple_server import WSGIServer
 
 from repo_quota import enforce_repository_size, install_quota_hook
+from mentions import MentionExtension, text_mentions_user
 
 import bleach
 import markdown
@@ -1020,6 +1021,7 @@ def render(template_name, **context):
     context.setdefault("csrf_field", csrf_field)
     context.setdefault("render_markdown", render_markdown)
     context.setdefault("render_markdown_links", render_markdown_links)
+    context.setdefault("render_mentions", render_mentions)
     context.setdefault("render_repo_description", render_repo_description)
     context.setdefault("format_ref_label", format_ref_label)
     context.setdefault("url_with_ref", url_with_ref)
@@ -1342,11 +1344,24 @@ def activity_sort_key(action):
     )
 
 
-def normalize_activity_action(row):
+def normalize_activity_action(row, mentioned_username=None):
     action = dict(row)
     actor_username = action.get("actor_username") or ""
     action["actor_label"] = f"@{actor_username}" if actor_username else ""
     action["actor_url"] = f"/{actor_username}" if actor_username else ""
+    mention_summaries = {
+        "issue_opened": "mentioned you in an issue",
+        "issue_commented": "mentioned you in a comment on issue",
+        "pull_request_opened": "mentioned you in a pull request",
+        "pull_request_commented": "mentioned you in a comment on pull request",
+        "commit_commented": "mentioned you in a comment on commit",
+    }
+    if mentioned_username and action["kind"] in mention_summaries and (
+        text_mentions_user(action["detail"], mentioned_username)
+        or (action["kind"] in {"issue_opened", "pull_request_opened"}
+            and text_mentions_user(action["target_label"], mentioned_username))
+    ):
+        action["summary"] = mention_summaries[action["kind"]]
     action["detail"] = text_preview(action["detail"])
     return action
 
@@ -1373,11 +1388,24 @@ def list_activity_repositories(limit=ACTIVITY_REPO_SCAN_LIMIT, user_id=None):
 
 def list_commit_activity_actions(limit, user_id=None):
     actions = []
+    user = get_user_by_id(user_id) if user_id is not None else None
+    own_names = {name.strip().casefold() for name in (user["username"], user["display_name"]) if name.strip()} if user else set()
     repositories = list_activity_repositories() if user_id is None else list_activity_repositories(user_id=user_id)
     for repo in repositories:
         path = repo_path(repo["owner_username"], repo["name"])
         try:
-            commits = commit_log(path, limit=limit)
+            batch_limit = max(100, limit) if own_names else limit
+            commits = commit_log(path, limit=batch_limit)
+            if own_names:
+                visible = []
+                offset = 0
+                while commits:
+                    visible.extend(commit for commit in commits if commit["author"].strip().casefold() not in own_names)
+                    if len(visible) >= limit or len(commits) < batch_limit:
+                        break
+                    offset += len(commits)
+                    commits = commit_log(path, limit=batch_limit, offset=offset)
+                commits = visible[:limit]
         except (GitCommandError, OSError):
             continue
         for commit in commits:
@@ -1403,6 +1431,7 @@ def list_recent_actions(limit=50, user_id=None, offset=0):
     limit = max(1, int(limit)) if user_id is not None else max(1, min(int(limit), 100))
     fetch_limit = offset + limit
     with db_connect() as conn:
+        conn.create_function("mentions_user", 2, text_mentions_user, deterministic=True)
         rows = conn.execute(
             """
             SELECT *
@@ -1616,36 +1645,46 @@ def list_recent_actions(limit=50, user_id=None, offset=0):
                     '' AS detail
                 FROM users
             )
-            WHERE occurred_at IS NOT NULL AND (
-                ? IS NULL
+            WHERE occurred_at IS NOT NULL
+                AND (:user_id IS NULL OR actor_username != (
+                    SELECT username FROM users WHERE id = :user_id
+                )) AND (
+                :user_id IS NULL
                 OR EXISTS (
                     SELECT 1 FROM repo_stars
                     JOIN repositories AS starred ON starred.id = repo_stars.repo_id
                     JOIN users AS owner ON owner.id = starred.owner_id
-                    WHERE repo_stars.user_id = ?
+                    WHERE repo_stars.user_id = :user_id
                         AND owner.username = repo_owner_username AND starred.name = repo_name
                 )
                 OR (kind LIKE 'pull_request_%' AND repo_owner_username = (
-                    SELECT username FROM users WHERE id = ?
+                    SELECT username FROM users WHERE id = :user_id
+                ))
+                OR (kind IN ('issue_opened', 'issue_commented', 'pull_request_opened',
+                    'pull_request_commented', 'commit_commented') AND (
+                    mentions_user(detail, (SELECT username FROM users WHERE id = :user_id))
+                    OR (kind IN ('issue_opened', 'pull_request_opened') AND
+                        mentions_user(target_label, (SELECT username FROM users WHERE id = :user_id)))
                 ))
                 OR (kind LIKE 'issue_%' AND target_url IN (
                     SELECT '/' || owner.username || '/' || repositories.name || '/issues/' || issues.number
                     FROM issues
                     JOIN repositories ON repositories.id = issues.repo_id
                     JOIN users AS owner ON owner.id = repositories.owner_id
-                    WHERE issues.author_id = ? OR EXISTS (
+                    WHERE issues.author_id = :user_id OR EXISTS (
                         SELECT 1 FROM issue_comments
-                        WHERE issue_comments.issue_id = issues.id AND issue_comments.author_id = ?
+                        WHERE issue_comments.issue_id = issues.id AND issue_comments.author_id = :user_id
                     )
                 ))
             )
             ORDER BY occurred_at DESC, kind DESC, target_url DESC
-            LIMIT ?
+            LIMIT :fetch_limit
             """,
-            (user_id, user_id, user_id, user_id, user_id, fetch_limit),
+            {"user_id": user_id, "fetch_limit": fetch_limit},
         ).fetchall()
 
-    actions = [normalize_activity_action(row) for row in rows]
+    user = get_user_by_id(user_id) if user_id is not None else None
+    actions = [normalize_activity_action(row, user["username"] if user else None) for row in rows]
     actions.extend(list_commit_activity_actions(fetch_limit, user_id=user_id))
     actions.sort(key=activity_sort_key, reverse=True)
     return actions[offset:fetch_limit]
@@ -2594,11 +2633,23 @@ def highlight_language_class(file_path):
     return HIGHLIGHT_LANGUAGE_BY_EXTENSION.get(path.suffix.lower(), "")
 
 
+def profile_mentions():
+    usernames = {}
+
+    def resolve(username):
+        if username not in usernames:
+            user = get_user_by_username(username)
+            usernames[username] = user["username"] if user else None
+        return usernames[username]
+
+    return MentionExtension(resolve)
+
+
 def render_markdown(text):
     text = SCRIPT_STYLE_RE.sub("", text or "")
     rendered = markdown.markdown(
         text,
-        extensions=MARKDOWN_EXTENSIONS,
+        extensions=[*MARKDOWN_EXTENSIONS, profile_mentions()],
         output_format="html5",
     )
     return bleach.clean(
@@ -2613,6 +2664,7 @@ def render_markdown(text):
 def render_markdown_links(text):
     rendered = markdown.markdown(
         html.escape(text or "", quote=False),
+        extensions=[profile_mentions()],
         output_format="html5",
     )
     return bleach.clean(
@@ -2624,6 +2676,10 @@ def render_markdown_links(text):
     ).strip()
 
 
+def render_mentions(text):
+    return render_markdown_links(text)
+
+
 def render_repo_description(text):
     return render_markdown_links(text)
 
diff --git a/mentions.py b/mentions.py
new file mode 100644
index 0000000..25db75c
--- /dev/null
+++ b/mentions.py
@@ -0,0 +1,54 @@
+"""Recognize profile mentions using Markdown's code/link/HTML boundaries."""
+from xml.etree.ElementTree import Element
+
+import markdown
+from markdown.extensions import Extension
+from markdown.inlinepatterns import InlineProcessor
+
+
+MENTION_PATTERN = r'(?<![\w@/\\])@([A-Za-z0-9][A-Za-z0-9._-]+)(?![\[email protected]])'
+
+
+class MentionProcessor(InlineProcessor):
+    ANCESTOR_EXCLUDES = ('a', 'code', 'pre')
+
+    def __init__(self, md, resolve):
+        super().__init__(MENTION_PATTERN, md)
+        self.resolve = resolve
+
+    def handleMatch(self, match, data):
+        # A sentence's trailing full stop is punctuation, not part of a handle.
+        name = match.group(1).rstrip('.')
+        username = self.resolve(name.lower()) if 2 <= len(name) <= 63 else None
+        if not username:
+            return None, None, None
+        end = match.end(0) - (len(match.group(1)) - len(name))
+        link = Element('a', {'href': '/' + username})
+        link.text = data[match.start(0):end]
+        return link, match.start(0), end
+
+
+class MentionExtension(Extension):
+    def __init__(self, resolve):
+        self.resolve = resolve
+        super().__init__()
+
+    def extendMarkdown(self, md):
+        # Run after code, escaping, raw HTML and ordinary Markdown links.
+        md.inlinePatterns.register(MentionProcessor(md, self.resolve), 'mentions', 75)
+
+
+def text_mentions_user(text, username):
+    if not text or not username or '@' + username.lower() not in text.lower():
+        return False
+    found = False
+
+    def resolve(name):
+        nonlocal found
+        if name == username.lower():
+            found = True
+            return name
+        return None
+
+    markdown.markdown(text, extensions=['extra', 'sane_lists', MentionExtension(resolve)])
+    return found
diff --git a/templates/issue_detail.tpl b/templates/issue_detail.tpl
index c374059..2da638e 100644
--- a/templates/issue_detail.tpl
+++ b/templates/issue_detail.tpl
@@ -13,7 +13,7 @@
 <section class="panel">
   <div class="panel-heading">
     <div>
-      <h2>(#{{issue["number"]}}) {{issue["title"]}}</h2>
+      <h2>(#{{issue["number"]}}) {{!render_mentions(issue["title"])}}</h2>
       <p class="muted"><strong>{{issue["status"]}}</strong> by {{issue["author_username"]}} on {{issue["created_at"]}}</p>
     </div>
     % if can_maintain:
@@ -30,7 +30,7 @@
     % end
   </div>
   % if issue["body"]:
-    <span>{{issue["body"]}}</span>
+    <div class="markdown-body">{{!render_markdown(issue["body"])}}</div>
   % else:
     <p class="empty">No description.</p>
   % end
diff --git a/templates/notifications.tpl b/templates/notifications.tpl
index 2647e77..2d4960e 100644
--- a/templates/notifications.tpl
+++ b/templates/notifications.tpl
@@ -4,14 +4,14 @@
   <div class="panel-heading">
     <div>
       <h1>Notifications</h1>
-      <p class="muted">Activity on your issues, pull requests in your repositories, and starred repositories.</p>
+      <p class="muted">Mentions and activity from other people on your issues, pull requests, and starred repositories.</p>
     </div>
   </div>
 
   % if actions:
     % include("activity_feed.tpl", actions=actions)
   % else:
-    <p class="empty">No notifications yet. Create or comment on an issue, star a repository, or receive a pull request to see activity here.</p>
+    <p class="empty">No notifications yet. Create or comment on an issue, star a repository, receive a pull request, or get @mentioned to see activity here.</p>
   % end
   % include("pagination.tpl", pagination=pagination)
 </section>
diff --git a/templates/pull_request_detail.tpl b/templates/pull_request_detail.tpl
index 8c0fd47..8498530 100644
--- a/templates/pull_request_detail.tpl
+++ b/templates/pull_request_detail.tpl
@@ -13,7 +13,7 @@
 <section class="panel">
   <div class="panel-heading">
     <div>
-      <h2>(#{{pr["number"]}}) {{pr["title"]}}</h2>
+      <h2>(#{{pr["number"]}}) {{!render_mentions(pr["title"])}}</h2>
       <p class="muted"><strong>{{pr["status"]}}</strong> <small>(created by <a href="/{{pr["author_username"]}}">{{pr["author_username"]}}</a> on {{pr["created_at"]}})</small></p>
       <p class="muted"><a href="/{{pr["source_owner_username"]}}/{{pr["source_repo_name"]}}">{{pr["source_owner_username"]}}/{{pr["source_repo_name"]}}</a> into <a href="/{{pr["target_owner_username"]}}/{{pr["target_repo_name"]}}">{{pr["target_owner_username"]}}/{{pr["target_repo_name"]}}</a></p>
       <p class="muted">{{format_ref_label(pr["source_ref_type"], pr["source_ref_name"])}} into {{format_ref_label(pr["target_ref_type"], pr["target_ref_name"])}}</p>
@@ -34,7 +34,7 @@
     % end
   </div>
   % if pr["body"]:
-    <pre class="readme">{{pr["body"]}}</pre>
+    <div class="markdown-body">{{!render_markdown(pr["body"])}}</div>
   % else:
     <p class="empty">No description.</p>
   % end
diff --git a/tests/test_app.py b/tests/test_app.py
index 2ad1776..41ac3f4 100644
--- a/tests/test_app.py
+++ b/tests/test_app.py
@@ -2853,15 +2853,15 @@ def test_notifications_include_starred_commits_and_owned_or_starred_pull_request
         repo = isolated_app.get_repo(name, "demo")
         repos[name] = repo
         isolated_app.create_pull_request(
-            repo, repo, owner, f"{name} feature request", "", "branch", "feature", "branch", "main",
+            repo, repo, owners["bob"] if name == "alice" else owner, f"{name} feature request", "", "branch", "feature", "branch", "main",
         )
     isolated_app.star_repo(owners["alice"], repos["bob"])
     client = WsgiClient(isolated_app.app)
     login_client(client, "alice")
-    client.post("/alice/demo/pulls/1", {"action": "comment", "body": "Owned PR comment"})
-    assert client.post("/alice/demo/pulls/1", {"action": "close"}).status_code == 303
     bob_client = WsgiClient(isolated_app.app)
     login_client(bob_client, "bob")
+    bob_client.post("/alice/demo/pulls/1", {"action": "comment", "body": "Owned PR comment"})
+    assert client.post("/alice/demo/pulls/1", {"action": "close"}).status_code == 303
     bob_client.post("/bob/demo/pulls/1", {"action": "comment", "body": "Starred PR comment"})
     assert bob_client.post("/bob/demo/pulls/1", {"action": "merge"}).status_code == 303
     # These projects share the same repo name: owner identity must also match.
@@ -2871,7 +2871,7 @@ def test_notifications_include_starred_commits_and_owned_or_starred_pull_request
     assert "carol feature request" not in feed
     assert "Owned PR comment" in feed
     assert "Starred PR comment" in feed
-    assert "closed pull request" in feed
+    assert "closed pull request" not in feed  # the viewer closed it
     assert "merged pull request" in feed
     assert "committed" in feed
     assert "/bob/demo/commits/" in feed
@@ -2881,3 +2881,121 @@ def test_notifications_include_starred_commits_and_owned_or_starred_pull_request
     assert "bob feature request" not in feed
     assert "/bob/demo/commits/" not in feed
     assert "alice feature request" in feed
+
+
+def test_notifications_exclude_own_actions_before_pagination(isolated_app, monkeypatch):
+    alice = create_user('alice')
+    bob = create_user('bob')
+    monkeypatch.setattr(isolated_app, 'utcnow', lambda: '2025-12-01T00:00:00Z')
+    isolated_app.create_repository(bob, 'demo', '')
+    repo = isolated_app.get_repo('bob', 'demo')
+    isolated_app.star_repo(alice, repo)
+    clients = {name: WsgiClient(isolated_app.app) for name in ['alice', 'bob']}
+    for name, client in clients.items():
+        login_client(client, name)
+    monkeypatch.setattr(isolated_app, 'utcnow', lambda: '2026-01-01T00:00:00Z')
+    clients['alice'].post('/bob/demo/issues/new', {'title': 'Own issue', 'body': '@alice self mention'})
+    clients['bob'].post('/bob/demo/issues/1', {'action': 'comment', 'body': 'Older reply from Bob'})
+    monkeypatch.setattr(isolated_app, 'utcnow', lambda: '2026-01-02T00:00:00Z')
+    clients['bob'].post('/bob/demo/issues/1', {'action': 'comment', 'body': 'Newer reply from Bob'})
+    monkeypatch.setattr(isolated_app, 'utcnow', lambda: '2026-01-03T00:00:00Z')
+    for i in range(4):
+        clients['alice'].post('/bob/demo/issues/1', {'action': 'comment', 'body': f'Own comment {i} @alice'})
+    first = clients['alice'].get('/-/notifications?per_page=1').text
+    second = clients['alice'].get('/-/notifications?per_page=1&page=2').text
+    assert 'Newer reply from Bob' in first
+    assert 'data-next-url=' in first
+    assert 'Older reply from Bob' in second
+    assert 'Newer reply from Bob' not in second
+    assert 'Own comment' not in first + second
+    assert 'self mention' not in first + second
+    assert 'Own comment' in clients['alice'].get('/').text
+
+
[email protected]('source', ['issue_title', 'issue_body', 'issue_comment', 'pr_title', 'pr_body', 'pr_comment', 'commit_comment'])
+def test_mentions_notify_nonparticipants_and_link_to_existing_profiles(isolated_app, source):
+    alice = create_user('alice')
+    bob = create_user('bob')
+    create_user('carol')
+    refs = create_repo_with_refs(bob)
+    repo = isolated_app.get_repo('bob', 'demo')
+    client = WsgiClient(isolated_app.app)
+    login_client(client, 'bob')
+    mention = 'Please review @AlIcE. Also @alice and @unknown-person'
+    if source.startswith('issue'):
+        assert client.post('/bob/demo/issues/new', {
+            'title': mention if source == 'issue_title' else 'Review issue',
+            'body': mention if source == 'issue_body' else '',
+        }).status_code == 303
+        target = '/bob/demo/issues/1'
+    elif source.startswith('pr'):
+        assert client.post('/bob/demo/pulls/new', {
+            'source_ref': isolated_app.source_ref_option_value(repo['id'], 'branch', 'feature'),
+            'target_ref': isolated_app.ref_option_value('branch', 'main'),
+            'title': mention if source == 'pr_title' else 'Review pull request',
+            'body': mention if source == 'pr_body' else '',
+        }).status_code == 303
+        target = '/bob/demo/pulls/1'
+    else:
+        target = '/bob/demo/commits/' + refs['default_node']
+    if source.endswith('comment'):
+        assert client.post(target, {'action': 'comment', 'body': mention}).status_code == 303
+    detail = client.get(target).text
+    assert '<a href="/alice">@AlIcE</a>.' in detail
+    assert '<a href="/unknown-person">' not in detail
+    recipient = WsgiClient(isolated_app.app)
+    login_client(recipient, 'alice')
+    feed = recipient.get('/-/notifications?per_page=1').text
+    assert f'href="{target}"' in feed
+    assert 'Please review' in feed
+    assert 'mentioned you' in feed
+    assert 'data-next-url=' not in feed  # repeated mentions yield one event
+    # Following the same repository must not duplicate a mention event.
+    mentioned = isolated_app.list_recent_actions(100, user_id=alice['id'])[0]
+    isolated_app.star_repo(alice, repo)
+    assert len([a for a in isolated_app.list_recent_actions(100, user_id=alice['id']) if a['target_url'] == target and a['kind'] == mentioned['kind']]) == 1
+    stranger = WsgiClient(isolated_app.app)
+    login_client(stranger, 'carol')
+    assert 'No notifications yet.' in stranger.get('/-/notifications').text
+    assert 'Please review' not in client.get('/-/notifications').text
+
+
[email protected]('body', [
+    '[email protected] and [email protected]',
+    '`@alice`',
+    '```python\n@alice\n```',
+    r'Escaped \@alice',
+    '[@alice](https://example.test/@alice)',
+    '<span title="@alice">ordinary text</span>',
+    '@alice-other @alicex',
+])
+def test_mentions_ignore_code_emails_links_and_nonmatching_names(isolated_app, body):
+    create_user('alice')
+    bob = create_user('bob')
+    isolated_app.create_repository(bob, 'demo', '')
+    client = WsgiClient(isolated_app.app)
+    login_client(client, 'bob')
+    assert client.post('/bob/demo/issues/new', {'title': 'Not a mention', 'body': body}).status_code == 303
+    recipient = WsgiClient(isolated_app.app)
+    login_client(recipient, 'alice')
+    assert 'No notifications yet.' in recipient.get('/-/notifications').text
+
+
+def test_notifications_hide_identifiable_own_commits_without_hiding_older_work(isolated_app):
+    alice = create_user('alice')
+    bob = create_user('bob')
+    isolated_app.create_repository(bob, 'demo', '')
+    repo = isolated_app.get_repo('bob', 'demo')
+    path = isolated_app.repo_path('bob', 'demo')
+    with isolated_app.db_connect() as conn:
+        conn.execute("UPDATE repositories SET created_at='2000-01-01T00:00:00Z' WHERE id=?", (repo['id'],))
+    commit_file(path, 'older.txt', 'older', message='Earlier Bob work', user='bob')
+    for i in range(4):
+        commit_file(path, f'self{i}.txt', str(i), message=f'Own commit {i}', user='Alice')
+    isolated_app.star_repo(alice, repo)
+    client = WsgiClient(isolated_app.app)
+    login_client(client, 'alice')
+    feed = client.get('/-/notifications?per_page=1').text
+    assert 'Earlier Bob work' in feed
+    assert 'Own commit' not in feed
+    assert 'Own commit' in client.get('/').text