patx/gitman

Add mention autocomplete and follow activity in owned repositories

Commit 5a613ad · patx · 2026-11-03T13:08:15-05:00

Changeset
5a613ad7390953e0795167e67a60aebbb881313b
Parents
09a3dd1790b6f988a744e32a1e171c0b2bee26bc

View source at this commit

Comments

No comments yet.

Log in to comment

Diff

diff --git a/README.md b/README.md
index 4f63f3a..b3c6b0b 100644
--- a/README.md
+++ b/README.md
@@ -34,12 +34,16 @@ Pages-style static hosting is driven by the Git repository contents. A user site
 ## Notifications
 
 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. Your own actions are excluded. Commit authors matching your
+feed. It includes activity on issues you created or commented on, plus activity
+(including commits, issues, and pull requests) in repositories you own or 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.
 
+Type `@` in issue/PR titles, descriptions, or comment fields to search accounts
+by username or display name. Pick a suggestion with the mouse or with the arrow
+keys and Enter/Tab; Escape dismisses the menu.
+
 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
@@ -50,7 +54,7 @@ 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,
+Commit activity scans up to `GITMAN_ACTIVITY_REPO_SCAN_LIMIT` owned or starred repositories,
 prioritizing the most recently updated repositories.
 
 ## Configuration
diff --git a/app.py b/app.py
index 72c3a83..0531e79 100644
--- a/app.py
+++ b/app.py
@@ -1374,7 +1374,7 @@ def list_activity_repositories(limit=ACTIVITY_REPO_SCAN_LIMIT, user_id=None):
             SELECT repositories.*, users.username AS owner_username
             FROM repositories
             JOIN users ON users.id = repositories.owner_id
-            WHERE (? IS NULL OR EXISTS (
+            WHERE (? IS NULL OR repositories.owner_id = ? OR EXISTS (
                 SELECT 1 FROM repo_stars
                 WHERE repo_stars.repo_id = repositories.id AND repo_stars.user_id = ?
             ))
@@ -1382,7 +1382,7 @@ def list_activity_repositories(limit=ACTIVITY_REPO_SCAN_LIMIT, user_id=None):
                 repositories.id DESC
             LIMIT ?
             """,
-            (user_id, user_id, user_id, limit),
+            (user_id, user_id, user_id, user_id, limit),
         ).fetchall()
 
 
@@ -1657,7 +1657,7 @@ def list_recent_actions(limit=50, user_id=None, offset=0):
                     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 = (
+                OR (repo_owner_username = (
                     SELECT username FROM users WHERE id = :user_id
                 ))
                 OR (kind IN ('issue_opened', 'issue_commented', 'pull_request_opened',
@@ -4949,6 +4949,30 @@ def notifications():
     )
 
 
[email protected]("/-/users/search")
+def mention_user_search():
+    require_login()
+    query = (request.query.get("q", "").strip().lstrip("@").lower())[:63]
+    prefix = escape_like_term(query) + "%"
+    with db_connect() as conn:
+        users = conn.execute(
+            """
+            SELECT username, display_name FROM users
+            WHERE username LIKE :prefix ESCAPE '\\'
+                OR display_name LIKE :prefix ESCAPE '\\'
+            ORDER BY CASE WHEN username = :query THEN 0
+                WHEN username LIKE :prefix ESCAPE '\\' THEN 1 ELSE 2 END, username
+            LIMIT 8
+            """,
+            {"prefix": prefix, "query": query},
+        ).fetchall()
+    return HTTPResponse(
+        json.dumps({"results": [dict(user) for user in users]}),
+        content_type="application/json",
+        headers={"Cache-Control": "no-store"},
+    )
+
+
 @app.route("/-/repos/search")
 def public_repo_search():
     results = search_public_repos(
diff --git a/static/mentions.js b/static/mentions.js
new file mode 100644
index 0000000..0670c0b
--- /dev/null
+++ b/static/mentions.js
@@ -0,0 +1,165 @@
+(() => {
+  document.querySelectorAll('[data-mentions]').forEach((input, fieldIndex) => {
+    const menu = document.createElement('div');
+    menu.className = 'repo-search-menu mention-menu';
+    menu.id = `mention-options-${fieldIndex}`;
+    menu.setAttribute('role', 'listbox');
+    menu.setAttribute('aria-label', 'Usernames');
+    menu.hidden = true;
+    document.body.append(menu);
+    input.setAttribute('autocomplete', 'off');
+    input.setAttribute('aria-autocomplete', 'list');
+    input.setAttribute('aria-controls', menu.id);
+    input.setAttribute('aria-expanded', 'false');
+
+    let results = [];
+    let activeIndex = -1;
+    let token = null;
+    let requestId = 0;
+    let timer;
+    let controller;
+
+    const currentToken = () => {
+      const caret = input.selectionStart;
+      if (caret === null || caret !== input.selectionEnd) return null;
+      const match = input.value.slice(0, caret).match(/(?:^|[^\w@/\\])@([a-z0-9._-]{0,63})$/i);
+      if (!match) return null;
+      return {
+        query: match[1],
+        start: caret - match[1].length - 1,
+        end: caret + (input.value.slice(caret).match(/^[a-z0-9._-]*/i) || [''])[0].length,
+      };
+    };
+
+    const close = () => {
+      clearTimeout(timer);
+      controller?.abort();
+      requestId += 1;
+      menu.hidden = true;
+      results = [];
+      activeIndex = -1;
+      input.setAttribute('aria-expanded', 'false');
+      input.removeAttribute('aria-activedescendant');
+    };
+
+    const position = () => {
+      const rect = input.getBoundingClientRect();
+      const below = window.innerHeight - rect.bottom - 12;
+      const above = rect.top - 12;
+      const placeAbove = below < 160 && above > below;
+      const height = Math.max(40, Math.min(256, placeAbove ? above : below));
+      menu.style.width = `${Math.min(Math.max(rect.width, 240), 360, window.innerWidth - 32)}px`;
+      menu.style.maxHeight = `${height}px`;
+      menu.style.left = `${Math.max(16, Math.min(rect.left, window.innerWidth - menu.offsetWidth - 16))}px`;
+      menu.style.top = `${placeAbove ? Math.max(8, rect.top - menu.offsetHeight - 4) : rect.bottom + 4}px`;
+    };
+
+    const highlight = (index) => {
+      activeIndex = index;
+      Array.from(menu.querySelectorAll('[role="option"]')).forEach((option, i) => {
+        option.classList.toggle('active', i === index);
+        option.setAttribute('aria-selected', String(i === index));
+        if (i === index) {
+          input.setAttribute('aria-activedescendant', option.id);
+          option.scrollIntoView({ block: 'nearest' });
+        }
+      });
+    };
+
+    const choose = (index) => {
+      const current = currentToken();
+      const user = results[index];
+      if (!user || !current || !token || current.start !== token.start || current.query !== token.query) return;
+      const suffix = input.value.slice(current.end);
+      const replacement = `@${user.username}${!suffix || !/^[\s.,;:!?)}\]]/.test(suffix) ? ' ' : ''}`;
+      if (input.maxLength > 0 && input.value.length - (current.end - current.start) + replacement.length > input.maxLength) return;
+      input.focus();
+      input.setRangeText(replacement, current.start, current.end, 'end');
+      close();
+      input.dispatchEvent(new Event('input', { bubbles: true }));
+    };
+
+    const render = (users, message = 'No users found.') => {
+      results = users;
+      menu.replaceChildren();
+      input.removeAttribute('aria-activedescendant');
+      activeIndex = -1;
+      users.forEach((user, index) => {
+        const option = document.createElement('button');
+        option.type = 'button';
+        option.tabIndex = -1;
+        option.className = 'repo-search-link mention-option';
+        option.id = `${menu.id}-${index}`;
+        option.setAttribute('role', 'option');
+        option.setAttribute('aria-selected', 'false');
+        const username = document.createElement('strong');
+        username.textContent = `@${user.username}`;
+        option.append(username);
+        if (user.display_name) {
+          const name = document.createElement('small');
+          name.textContent = user.display_name;
+          option.append(name);
+        }
+        option.addEventListener('mousedown', (event) => event.preventDefault());
+        option.addEventListener('click', () => choose(index));
+        menu.append(option);
+      });
+      if (!users.length) {
+        const status = document.createElement('div');
+        status.className = 'ref-picker-empty';
+        status.setAttribute('role', 'status');
+        status.textContent = message;
+        menu.append(status);
+      }
+      menu.hidden = false;
+      input.setAttribute('aria-expanded', 'true');
+      position();
+      if (users.length) highlight(0);
+    };
+
+    const search = () => {
+      close();
+      token = currentToken();
+      if (!token || document.activeElement !== input) return;
+      const nextToken = token;
+      const id = requestId;
+      timer = setTimeout(async () => {
+        controller = new AbortController();
+        try {
+          const url = new URL('/-/users/search', window.location.origin);
+          url.searchParams.set('q', nextToken.query);
+          const response = await fetch(url, { signal: controller.signal, headers: { Accept: 'application/json' } });
+          if (!response.ok) throw new Error('Username search failed');
+          const data = await response.json();
+          const current = currentToken();
+          if (id !== requestId || document.activeElement !== input || !current || current.start !== nextToken.start || current.query !== nextToken.query) return;
+          render(data.results || []);
+        } catch (error) {
+          if (error.name !== 'AbortError' && id === requestId) render([], 'Unable to load usernames.');
+        }
+      }, 120);
+    };
+
+    input.addEventListener('input', (event) => { if (!event.isComposing) search(); else close(); });
+    input.addEventListener('compositionend', search);
+    input.addEventListener('click', search);
+    input.addEventListener('keyup', (event) => { if (['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) search(); });
+    input.addEventListener('keydown', (event) => {
+      if (menu.hidden || event.isComposing) return;
+      if (event.key === 'Escape') {
+        event.preventDefault();
+        close();
+      } else if (results.length && ['ArrowDown', 'ArrowUp'].includes(event.key)) {
+        event.preventDefault();
+        highlight((activeIndex + (event.key === 'ArrowDown' ? 1 : results.length - 1)) % results.length);
+      } else if (results.length && ['Enter', 'Tab'].includes(event.key)) {
+        event.preventDefault();
+        choose(activeIndex);
+      }
+    });
+    input.addEventListener('blur', (event) => { if (!menu.contains(event.relatedTarget)) close(); });
+    document.addEventListener('pointerdown', (event) => { if (event.target !== input && !menu.contains(event.target)) close(); });
+    window.addEventListener('resize', () => { if (!menu.hidden) position(); });
+    window.addEventListener('scroll', () => { if (!menu.hidden) position(); }, { passive: true, capture: true });
+  });
+})();
diff --git a/static/styles.css b/static/styles.css
index 2230f2f..7c679c7 100644
--- a/static/styles.css
+++ b/static/styles.css
@@ -402,3 +402,23 @@ button, .button {
 .password-dialog .muted { font-size: .875rem; }
 .password-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: .75rem; }
 .password-dialog form { margin-bottom: 0; }
+
+.mention-menu {
+  position: fixed;
+  right: auto;
+  margin: 0;
+}
+.mention-option {
+  flex-shrink: 0;
+  border: 0;
+  background: transparent;
+  text-align: left;
+  cursor: pointer;
+}
+.mention-option.active { background: var(--ref-hover); }
+.mention-option small {
+  color: var(--muted);
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
diff --git a/templates/base.tpl b/templates/base.tpl
index 9f733e4..fe5774f 100644
--- a/templates/base.tpl
+++ b/templates/base.tpl
@@ -9,7 +9,7 @@
 <link rel="apple-touch-icon" sizes="180x180" href="/static/git.svg">
   <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css">
   <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css" media="(prefers-color-scheme: dark)">
-  <link rel="stylesheet" href="/static/styles.css?v=7">
+  <link rel="stylesheet" href="/static/styles.css?v=8">
 </head>
 <body>
   <header class="site-header">
@@ -56,6 +56,7 @@
     % end
     {{!base}}
   </main>
+  <script src="/static/mentions.js?v=1" defer></script>
   <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
   <script>
     (() => {
diff --git a/templates/commit_detail.tpl b/templates/commit_detail.tpl
index 456cfc5..037f7b4 100644
--- a/templates/commit_detail.tpl
+++ b/templates/commit_detail.tpl
@@ -47,7 +47,7 @@
       <input type="hidden" name="action" value="comment">
       <label>
         Add a comment
-        <textarea name="body" rows="5">{{comment_value}}</textarea>
+        <textarea data-mentions name="body" rows="5">{{comment_value}}</textarea>
       </label>
       <button class="button" type="submit">Comment</button>
     </form>
diff --git a/templates/issue_detail.tpl b/templates/issue_detail.tpl
index 2da638e..f6173e3 100644
--- a/templates/issue_detail.tpl
+++ b/templates/issue_detail.tpl
@@ -54,7 +54,7 @@
       <input type="hidden" name="action" value="comment">
       <label>
         Add a comment
-        <textarea name="body" rows="5">{{comment_value}}</textarea>
+        <textarea data-mentions name="body" rows="5">{{comment_value}}</textarea>
       </label>
       <button class="button" type="submit">Comment</button>
     </form>
diff --git a/templates/new_issue.tpl b/templates/new_issue.tpl
index 9ea32ab..fc9c65d 100644
--- a/templates/new_issue.tpl
+++ b/templates/new_issue.tpl
@@ -16,11 +16,11 @@
     {{!csrf_field()}}
     <label>
       Title
-      <input name="title" value="{{title_value}}" required maxlength="200">
+      <input data-mentions name="title" value="{{title_value}}" required maxlength="200">
     </label>
     <label>
       Body
-      <textarea name="body" rows="8">{{body_value}}</textarea>
+      <textarea data-mentions name="body" rows="8">{{body_value}}</textarea>
     </label>
     <button class="button" type="submit">Open issue</button>
   </form>
diff --git a/templates/new_pull_request.tpl b/templates/new_pull_request.tpl
index fd19666..88e096b 100644
--- a/templates/new_pull_request.tpl
+++ b/templates/new_pull_request.tpl
@@ -33,11 +33,11 @@
       </label>
       <label>
         Title
-        <input name="title" value="{{title_value}}" required maxlength="200">
+        <input data-mentions name="title" value="{{title_value}}" required maxlength="200">
       </label>
       <label>
         Body
-        <textarea name="body" rows="8">{{body_value}}</textarea>
+        <textarea data-mentions name="body" rows="8">{{body_value}}</textarea>
       </label>
       <button class="button" type="submit">Open pull request</button>
     </form>
diff --git a/templates/notifications.tpl b/templates/notifications.tpl
index 2d4960e..f5ccf9f 100644
--- a/templates/notifications.tpl
+++ b/templates/notifications.tpl
@@ -4,7 +4,7 @@
   <div class="panel-heading">
     <div>
       <h1>Notifications</h1>
-      <p class="muted">Mentions and activity from other people on your issues, pull requests, and starred repositories.</p>
+      <p class="muted">Mentions and activity from other people in repositories you own or have starred, and on issues you follow.</p>
     </div>
   </div>
 
diff --git a/templates/pull_request_detail.tpl b/templates/pull_request_detail.tpl
index 8498530..0ecb377 100644
--- a/templates/pull_request_detail.tpl
+++ b/templates/pull_request_detail.tpl
@@ -61,7 +61,7 @@
       <input type="hidden" name="action" value="comment">
       <label>
         Add a comment
-        <textarea name="body" rows="5">{{comment_value}}</textarea>
+        <textarea data-mentions name="body" rows="5">{{comment_value}}</textarea>
       </label>
       <button class="button" type="submit">Comment</button>
     </form>
diff --git a/tests/test_app.py b/tests/test_app.py
index 41ac3f4..c4806ef 100644
--- a/tests/test_app.py
+++ b/tests/test_app.py
@@ -2861,6 +2861,8 @@ def test_notifications_include_starred_commits_and_owned_or_starred_pull_request
     bob_client = WsgiClient(isolated_app.app)
     login_client(bob_client, "bob")
     bob_client.post("/alice/demo/pulls/1", {"action": "comment", "body": "Owned PR comment"})
+    bob_client.post("/alice/demo/issues/new", {"title": "Issue from Bob in owned repo", "body": "Owned issue activity"})
+    commit_file(isolated_app.repo_path("alice", "demo"), "bob.txt", "Bob work", message="Bob commit in owned repo", user="bob")
     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
@@ -2870,6 +2872,8 @@ def test_notifications_include_starred_commits_and_owned_or_starred_pull_request
     assert "bob feature request" in feed
     assert "carol feature request" not in feed
     assert "Owned PR comment" in feed
+    assert "Issue from Bob in owned repo" in feed
+    assert "Bob commit in owned repo" in feed
     assert "Starred PR comment" in feed
     assert "closed pull request" not in feed  # the viewer closed it
     assert "merged pull request" in feed
@@ -2999,3 +3003,26 @@ def test_notifications_hide_identifiable_own_commits_without_hiding_older_work(i
     assert 'Earlier Bob work' in feed
     assert 'Own commit' not in feed
     assert 'Own commit' in client.get('/').text
+
+
+def test_mention_autocomplete_searches_public_names_without_exposing_account_data(isolated_app):
+    alice = create_user('alice')
+    create_user('alicia')
+    create_user('bob')
+    create_user('alice_dev')
+    with isolated_app.db_connect() as conn:
+        conn.execute("UPDATE users SET display_name='Alice Smith' WHERE username='bob'")
+    client = WsgiClient(isolated_app.app)
+    assert client.get('/-/users/search?q=ali').status_code == 303
+    login_client(client, 'alice')
+    response = client.get('/-/users/search?q=ALICE')
+    assert response.status_code == 200
+    assert response.header('Cache-Control') == 'no-store'
+    results = json.loads(response.text)['results']
+    assert [user['username'] for user in results] == ['alice', 'alice_dev', 'bob']
+    assert all(set(user) == {'username', 'display_name'} for user in results)
+    assert json.loads(client.get('/-/users/search?q=alice_').text)['results'] == [{'username': 'alice_dev', 'display_name': ''}]
+    assert json.loads(client.get('/-/users/search?q=%25').text)['results'] == []
+    for i in range(10):
+        create_user(f'person{i}')
+    assert len(json.loads(client.get('/-/users/search').text)['results']) == 8