patx/gitman
Add mention autocomplete and follow activity in owned repositories
Commit 5a613ad · patx · 2026-11-03T13:08:15-05:00
Comments
No comments yet.
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