patx/gitman
Add personal notifications feed with infinite scroll
Commit 26ceefa · patx · 2026-11-02T23:50:17-05:00
Comments
No comments yet.
Diff
diff --git a/README.md b/README.md
index 43b4e6d..7047713 100644
--- a/README.md
+++ b/README.md
@@ -31,6 +31,17 @@ Issues, comments, stars, contributors, forks, and pull request records are store
Pages-style static hosting is driven by the Git repository contents. A user site repository named `<username>.<GITMAN_PAGES_DOMAIN>` is served from its repository root at `https://<username>.<GITMAN_PAGES_DOMAIN>/`; project Pages can be enabled in repository settings and are served from that repository's `docs/` directory. Any published Pages repository can advertise a custom domain with a DNS TXT verification record: user sites use a root `CNAME` file, and project sites use `docs/CNAME`. A custom domain on the user site also serves that user's enabled project Pages at `https://<custom-domain>/<repo>/`.
+## 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. 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.
+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.
+
## Configuration
- `SECRET_KEY`: Bottle signed-cookie secret
diff --git a/app.py b/app.py
index 357ddc2..4ef38d2 100644
--- a/app.py
+++ b/app.py
@@ -394,6 +394,14 @@ def init_db():
updated_at TEXT NOT NULL
);
+ CREATE TABLE IF NOT EXISTS issue_status_events (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ issue_id INTEGER NOT NULL REFERENCES issues(id) ON DELETE CASCADE,
+ author_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ status TEXT NOT NULL,
+ created_at TEXT NOT NULL
+ );
+
CREATE TABLE IF NOT EXISTS repo_contributors (
repo_id INTEGER NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
@@ -433,6 +441,14 @@ def init_db():
UNIQUE(target_repo_id, number)
);
+ CREATE TABLE IF NOT EXISTS pull_request_status_events (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ pull_request_id INTEGER NOT NULL REFERENCES pull_requests(id) ON DELETE CASCADE,
+ author_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ status TEXT NOT NULL,
+ created_at TEXT NOT NULL
+ );
+
CREATE TABLE IF NOT EXISTS pull_request_comments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
pull_request_id INTEGER NOT NULL REFERENCES pull_requests(id) ON DELETE CASCADE,
@@ -498,10 +514,14 @@ def init_db():
CREATE INDEX IF NOT EXISTS idx_issues_repo_number ON issues(repo_id, number);
CREATE INDEX IF NOT EXISTS idx_issues_repo_status ON issues(repo_id, status);
CREATE INDEX IF NOT EXISTS idx_issue_comments_issue ON issue_comments(issue_id);
+ CREATE INDEX IF NOT EXISTS idx_issue_comments_author ON issue_comments(author_id, issue_id);
+ CREATE INDEX IF NOT EXISTS idx_issues_author ON issues(author_id);
+ CREATE INDEX IF NOT EXISTS idx_issue_status_events_issue ON issue_status_events(issue_id);
CREATE INDEX IF NOT EXISTS idx_repo_contributors_user ON repo_contributors(user_id);
CREATE INDEX IF NOT EXISTS idx_repo_stars_user ON repo_stars(user_id);
CREATE INDEX IF NOT EXISTS idx_pull_requests_target_status ON pull_requests(target_repo_id, status);
CREATE INDEX IF NOT EXISTS idx_pull_requests_source ON pull_requests(source_repo_id);
+ CREATE INDEX IF NOT EXISTS idx_pull_request_status_events_pull_request ON pull_request_status_events(pull_request_id);
CREATE INDEX IF NOT EXISTS idx_pull_request_comments_pull_request ON pull_request_comments(pull_request_id);
CREATE INDEX IF NOT EXISTS idx_commit_comments_commit ON commit_comments(repo_id, commit_node);
CREATE INDEX IF NOT EXISTS idx_custom_domains_domain ON custom_domains(domain);
@@ -1331,7 +1351,7 @@ def normalize_activity_action(row):
return action
-def list_activity_repositories(limit=ACTIVITY_REPO_SCAN_LIMIT):
+def list_activity_repositories(limit=ACTIVITY_REPO_SCAN_LIMIT, user_id=None):
limit = max(1, min(int(limit), ACTIVITY_REPO_SCAN_LIMIT))
with db_connect() as conn:
return conn.execute(
@@ -1339,16 +1359,22 @@ def list_activity_repositories(limit=ACTIVITY_REPO_SCAN_LIMIT):
SELECT repositories.*, users.username AS owner_username
FROM repositories
JOIN users ON users.id = repositories.owner_id
- ORDER BY repositories.created_at DESC
+ WHERE (? IS NULL OR EXISTS (
+ SELECT 1 FROM repo_stars
+ WHERE repo_stars.repo_id = repositories.id AND repo_stars.user_id = ?
+ ))
+ ORDER BY CASE WHEN ? IS NULL THEN repositories.created_at ELSE repositories.updated_at END DESC,
+ repositories.id DESC
LIMIT ?
""",
- (limit,),
+ (user_id, user_id, user_id, limit),
).fetchall()
-def list_commit_activity_actions(limit):
+def list_commit_activity_actions(limit, user_id=None):
actions = []
- for repo in list_activity_repositories():
+ 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)
@@ -1373,8 +1399,9 @@ def list_commit_activity_actions(limit):
return actions
-def list_recent_actions(limit=50):
- limit = max(1, min(int(limit), 100))
+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:
rows = conn.execute(
"""
@@ -1471,6 +1498,24 @@ def list_recent_actions(limit=50):
UNION ALL
+ SELECT
+ 'issue_' || issue_status_events.status AS kind,
+ issue_status_events.created_at AS occurred_at,
+ actor.username AS actor_username,
+ owner.username AS repo_owner_username,
+ repositories.name AS repo_name,
+ '/' || owner.username || '/' || repositories.name || '/issues/' || issues.number AS target_url,
+ '#' || issues.number || ' ' || issues.title AS target_label,
+ CASE issue_status_events.status WHEN 'closed' THEN 'closed issue' ELSE 'reopened issue' END AS summary,
+ '' AS detail
+ FROM issue_status_events
+ JOIN users AS actor ON actor.id = issue_status_events.author_id
+ JOIN issues ON issues.id = issue_status_events.issue_id
+ JOIN repositories ON repositories.id = issues.repo_id
+ JOIN users AS owner ON owner.id = repositories.owner_id
+
+ UNION ALL
+
SELECT
'pull_request_opened' AS kind,
pull_requests.created_at AS occurred_at,
@@ -1506,6 +1551,24 @@ def list_recent_actions(limit=50):
UNION ALL
+ SELECT
+ 'pull_request_closed' AS kind,
+ pull_request_status_events.created_at AS occurred_at,
+ actor.username AS actor_username,
+ owner.username AS repo_owner_username,
+ repositories.name AS repo_name,
+ '/' || owner.username || '/' || repositories.name || '/pulls/' || pull_requests.number AS target_url,
+ '#' || pull_requests.number || ' ' || pull_requests.title AS target_label,
+ 'closed pull request' AS summary,
+ '' AS detail
+ FROM pull_request_status_events
+ JOIN users AS actor ON actor.id = pull_request_status_events.author_id
+ JOIN pull_requests ON pull_requests.id = pull_request_status_events.pull_request_id
+ JOIN repositories ON repositories.id = pull_requests.target_repo_id
+ JOIN users AS owner ON owner.id = repositories.owner_id
+
+ UNION ALL
+
SELECT
'pull_request_merged' AS kind,
pull_requests.merged_at AS occurred_at,
@@ -1553,17 +1616,39 @@ def list_recent_actions(limit=50):
'' AS detail
FROM users
)
- WHERE occurred_at IS NOT NULL
- ORDER BY occurred_at DESC
+ WHERE occurred_at IS NOT NULL AND (
+ ? 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 = ?
+ 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 = ?
+ ))
+ 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 (
+ SELECT 1 FROM issue_comments
+ WHERE issue_comments.issue_id = issues.id AND issue_comments.author_id = ?
+ )
+ ))
+ )
+ ORDER BY occurred_at DESC, kind DESC, target_url DESC
LIMIT ?
""",
- (limit,),
+ (user_id, user_id, user_id, user_id, user_id, fetch_limit),
).fetchall()
actions = [normalize_activity_action(row) for row in rows]
- actions.extend(list_commit_activity_actions(limit))
+ actions.extend(list_commit_activity_actions(fetch_limit, user_id=user_id))
actions.sort(key=activity_sort_key, reverse=True)
- return actions[:limit]
+ return actions[offset:fetch_limit]
def owned_repo_count(owner_id):
@@ -4795,6 +4880,19 @@ def index():
return render("index.tpl", actions=list_recent_actions(50))
[email protected]("/-/notifications")
+def notifications():
+ user = require_login()
+ page = request_page()
+ per_page = request_per_page(25)
+ actions = list_recent_actions(per_page + 1, user_id=user["id"], offset=page_offset(page, per_page))
+ return render(
+ "notifications.tpl",
+ actions=actions[:per_page],
+ pagination=pagination_metadata(page, per_page, has_next=len(actions) > per_page),
+ )
+
+
@app.route("/-/repos/search")
def public_repo_search():
results = search_public_repos(
@@ -5608,6 +5706,10 @@ def pull_request_detail(owner, repo_name, number):
""",
(now, now, pr["id"]),
)
+ conn.execute(
+ "INSERT INTO pull_request_status_events (pull_request_id, author_id, status, created_at) VALUES (?, ?, 'closed', ?)",
+ (pr["id"], user["id"], now),
+ )
redirect(f"/{owner}/{repo_name}/pulls/{number}")
return render_pull_request_detail(repo, path, pr)
@@ -5730,12 +5832,20 @@ def issue_detail(owner, repo_name, number):
"UPDATE issues SET status = 'closed', updated_at = ?, closed_at = ? WHERE id = ?",
(now, now, issue["id"]),
)
+ conn.execute(
+ "INSERT INTO issue_status_events (issue_id, author_id, status, created_at) VALUES (?, ?, 'closed', ?)",
+ (issue["id"], user["id"], now),
+ )
elif action == "reopen" and issue["status"] != "open":
with db_connect() as conn:
conn.execute(
"UPDATE issues SET status = 'open', updated_at = ?, closed_at = NULL WHERE id = ?",
(now, issue["id"]),
)
+ conn.execute(
+ "INSERT INTO issue_status_events (issue_id, author_id, status, created_at) VALUES (?, ?, 'open', ?)",
+ (issue["id"], user["id"], now),
+ )
redirect(f"/{owner}/{repo_name}/issues/{number}")
return render(
diff --git a/templates/activity_feed.tpl b/templates/activity_feed.tpl
new file mode 100644
index 0000000..c2983c9
--- /dev/null
+++ b/templates/activity_feed.tpl
@@ -0,0 +1,26 @@
+ <ol class="activity-feed" data-paginated-list>
+ % for action in actions:
+ % repo_url = "/" + action["repo_owner_username"] + "/" + action["repo_name"] if action["repo_owner_username"] and action["repo_name"] else ""
+ % show_repo_context = repo_url and action["target_url"] != repo_url
+ <li>
+ <p class="activity-title">
+ % if action["actor_url"]:
+ <strong><a href="{{action['actor_url']}}">{{action["actor_label"]}}</a></strong>
+ % else:
+ <strong>{{action["actor_label"]}}</strong>
+ % end
+ {{action["summary"]}}
+ <a href="{{action['target_url']}}">{{action["target_label"]}}</a>
+ </p>
+ <p class="muted">
+ % if show_repo_context:
+ in <a href="{{repo_url}}">{{action["repo_owner_username"]}}/{{action["repo_name"]}}</a> ·
+ % end
+ {{action["occurred_at"]}}
+ </p>
+ % if action["detail"]:
+ <p class="activity-detail">{{!render_markdown_links(action["detail"])}}</p>
+ % end
+ </li>
+ % end
+ </ol>
diff --git a/templates/base.tpl b/templates/base.tpl
index 4889f5c..1d83905 100644
--- a/templates/base.tpl
+++ b/templates/base.tpl
@@ -24,6 +24,11 @@
</a>
<nav class="nav">
% if user:
+ <a class="nav-icons" href="/-/notifications" aria-label="Notifications" title="Notifications">
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 16 16" aria-hidden="true">
+ <path d="M8 16a2 2 0 0 0 2-2H6a2 2 0 0 0 2 2M8 1.918l-.797.161A4 4 0 0 0 4 6c0 .628-.134 2.197-.459 3.742-.16.767-.376 1.566-.663 2.258h10.244c-.287-.692-.502-1.49-.663-2.258C12.134 8.197 12 6.628 12 6a4 4 0 0 0-3.203-3.92zM14.22 12c.223.447.481.801.78 1H1c.299-.199.557-.553.78-1C2.68 10.2 3 6.88 3 6c0-2.42 1.72-4.44 4.005-4.901a1 1 0 1 1 1.99 0A5 5 0 0 1 13 6c0 .88.32 4.2 1.22 6"/>
+ </svg>
+ </a>
<a class="nav-icons" href="/{{user['username']}}">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="bi bi-person" viewBox="0 0 16 16">
<path d="M8 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6m2-3a2 2 0 1 1-4 0 2 2 0 0 1 4 0m4 8c0 1-1 1-1 1H3s-1 0-1-1 1-4 6-4 6 3 6 4m-1-.004c-.001-.246-.154-.986-.832-1.664C11.516 10.68 10.289 10 8 10s-3.516.68-4.168 1.332c-.678.678-.83 1.418-.832 1.664z"/>
diff --git a/templates/index.tpl b/templates/index.tpl
index eee0897..c85e177 100644
--- a/templates/index.tpl
+++ b/templates/index.tpl
@@ -34,32 +34,7 @@
% if actions:
<h1>Recent Activity Feed</h1>
- <ol class="activity-feed">
- % for action in actions:
- % repo_url = "/" + action["repo_owner_username"] + "/" + action["repo_name"] if action["repo_owner_username"] and action["repo_name"] else ""
- % show_repo_context = repo_url and action["target_url"] != repo_url
- <li>
- <p class="activity-title">
- % if action["actor_url"]:
- <strong><a href="{{action['actor_url']}}">{{action["actor_label"]}}</a></strong>
- % else:
- <strong>{{action["actor_label"]}}</strong>
- % end
- {{action["summary"]}}
- <a href="{{action['target_url']}}">{{action["target_label"]}}</a>
- </p>
- <p class="muted">
- % if show_repo_context:
- in <a href="{{repo_url}}">{{action["repo_owner_username"]}}/{{action["repo_name"]}}</a> ·
- % end
- {{action["occurred_at"]}}
- </p>
- % if action["detail"]:
- <p class="activity-detail">{{!render_markdown_links(action["detail"])}}</p>
- % end
- </li>
- % end
- </ol>
+ % include("activity_feed.tpl", actions=actions)
% else:
<p class="empty">No activity yet.</p>
% end
diff --git a/templates/notifications.tpl b/templates/notifications.tpl
new file mode 100644
index 0000000..2647e77
--- /dev/null
+++ b/templates/notifications.tpl
@@ -0,0 +1,17 @@
+% rebase("base.tpl", title="Notifications · GitMan", user=user, error=error, notice=notice)
+
+<section class="panel" style="border-bottom: none;">
+ <div class="panel-heading">
+ <div>
+ <h1>Notifications</h1>
+ <p class="muted">Activity on your issues, pull requests in your repositories, 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>
+ % end
+ % include("pagination.tpl", pagination=pagination)
+</section>
diff --git a/tests/test_app.py b/tests/test_app.py
index 53275e6..d4c1199 100644
--- a/tests/test_app.py
+++ b/tests/test_app.py
@@ -2779,3 +2779,97 @@ def test_direct_push_moves_repository_to_top_of_profile(isolated_app):
commit_file(isolated_app.repo_path('alice', 'older'), 'README.md', 'New work')
after = client.get('/alice').text
assert after.index('<strong>alice/older') < after.index('<strong>alice/newer')
+
+
+def test_notifications_require_login_and_show_an_empty_feed(isolated_app):
+ client = WsgiClient(isolated_app.app)
+ response = client.get("/-/notifications")
+ assert response.status_code == 303
+ assert response.location_path == "/login?next=/-/notifications"
+ create_user("alice")
+ login_client(client, "alice")
+ response = client.get("/-/notifications")
+ assert response.status_code == 200
+ assert "No notifications yet." in response.text
+ assert 'aria-label="Notifications"' in response.text
+
+
+def test_notifications_follow_created_and_commented_issues_with_status_history(isolated_app, monkeypatch):
+ create_user("alice")
+ bob = create_user("bob")
+ isolated_app.create_repository(bob, "demo", "")
+ alice_client = WsgiClient(isolated_app.app)
+ bob_client = WsgiClient(isolated_app.app)
+ login_client(alice_client, "alice")
+ login_client(bob_client, "bob")
+ monkeypatch.setattr(isolated_app, "utcnow", lambda: "2026-01-01T00:00:00Z")
+ assert alice_client.post("/bob/demo/issues/new", {"title": "Authored issue", "body": ""}).status_code == 303
+ assert bob_client.post("/bob/demo/issues/new", {"title": "Participated issue", "body": ""}).status_code == 303
+ alice_client.post("/bob/demo/issues/2", {"action": "comment", "body": "Following this"})
+ monkeypatch.setattr(isolated_app, "utcnow", lambda: "2026-01-02T00:00:00Z")
+ bob_client.post("/bob/demo/issues/1", {"action": "comment", "body": "Author update"})
+ bob_client.post("/bob/demo/issues/2", {"action": "comment", "body": "Participant update"})
+ monkeypatch.setattr(isolated_app, "utcnow", lambda: "2026-01-03T00:00:00Z")
+ bob_client.post("/bob/demo/issues/1", {"action": "close"})
+ monkeypatch.setattr(isolated_app, "utcnow", lambda: "2026-01-04T00:00:00Z")
+ bob_client.post("/bob/demo/issues/1", {"action": "reopen"})
+ # Newer unrelated activity must not displace followed issues before filtering.
+ monkeypatch.setattr(isolated_app, "utcnow", lambda: "2026-01-05T00:00:00Z")
+ bob_client.post("/bob/demo/issues/new", {"title": "Unrelated issue", "body": ""})
+ first = alice_client.get("/-/notifications?per_page=2")
+ assert first.status_code == 200
+ assert "reopened issue" in first.text
+ assert "closed issue" in first.text
+ assert "Unrelated issue" not in first.text
+ assert "data-next-url=" in first.text
+ second = alice_client.get("/-/notifications?per_page=2&page=2")
+ assert "Author update" in second.text
+ assert "Participant update" in second.text
+ assert "closed issue" not in second.text
+ assert "reopened issue" not in second.text
+ # Status events survive later comments and remain personal to participants.
+ bob_client.post("/bob/demo/issues/1", {"action": "comment", "body": "Later update"})
+ feed = alice_client.get("/-/notifications").text
+ assert all(text in feed for text in ("closed issue", "reopened issue", "Later update"))
+ create_user("carol")
+ carol_client = WsgiClient(isolated_app.app)
+ login_client(carol_client, "carol")
+ assert "No notifications yet." in carol_client.get("/-/notifications").text
+
+
+def test_notifications_include_starred_commits_and_owned_or_starred_pull_requests(isolated_app):
+ owners = {name: create_user(name) for name in ("alice", "bob", "carol")}
+ repos = {}
+ for name, owner in owners.items():
+ create_repo_with_refs(owner)
+ 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",
+ )
+ 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("/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.
+ feed = client.get("/-/notifications").text
+ assert "alice feature request" in feed
+ assert "bob feature request" in feed
+ 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 "merged pull request" in feed
+ assert "committed" in feed
+ assert "/bob/demo/commits/" in feed
+ assert "/carol/demo/commits/" not in feed
+ isolated_app.unstar_repo(owners["alice"], repos["bob"])
+ feed = client.get("/-/notifications").text
+ assert "bob feature request" not in feed
+ assert "/bob/demo/commits/" not in feed
+ assert "alice feature request" in feed