vextoly/gitman

Fork of patx/gitman.

Add personal notifications feed with infinite scroll

Commit 26ceefa · patx · 2026-11-02T23:50:17-05:00

Changeset
26ceefa19e837fce55ffbf960745f5caeccb16e7
Parents
44519f28ca9156c2314ad19e942a9dc96083c911

View source at this commit

Comments

No comments yet.

Log in to comment

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