Fork of patx/hglab.

added comments on commits, changed the index page to a recent activity feed

Commit e659e2eaf657 · Harrison Erd · 2026-05-04 17:44 -0400

Changeset
e659e2eaf6570a0f68784a51fed54b624d55259e

View source at this commit

Comments

No comments yet.

Log in to comment

Diff

diff --git a/README.md b/README.md
--- a/README.md
+++ b/README.md
@@ -2,7 +2,7 @@
 
 A small Bottle app that hosts public Mercurial repositories with local user accounts.
 
-Features include public user profiles, repository browsing, commit diffs, README rendering, issues with comments, fork-based pull requests, repository contributors, and HTTP Mercurial clone/pull/push.
+Features include public user profiles, repository browsing, commit diffs with comments, README rendering, issues with comments, fork-based pull requests, repository contributors, and HTTP Mercurial clone/pull/push.
 
 ## Run locally
 
diff --git a/app.py b/app.py
--- a/app.py
+++ b/app.py
@@ -256,6 +256,16 @@
                 updated_at TEXT NOT NULL
             );
 
+            CREATE TABLE IF NOT EXISTS commit_comments (
+                id INTEGER PRIMARY KEY AUTOINCREMENT,
+                repo_id INTEGER NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
+                commit_node TEXT NOT NULL,
+                author_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+                body TEXT NOT NULL,
+                created_at TEXT NOT NULL,
+                updated_at TEXT NOT NULL
+            );
+
             CREATE INDEX IF NOT EXISTS idx_repositories_owner ON repositories(owner_id);
             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);
@@ -265,6 +275,7 @@
             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_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);
             """
         )
         ensure_user_profile_columns(conn)
@@ -476,6 +487,285 @@
         ).fetchall()
 
 
+def text_preview(value, limit=180):
+    text = " ".join((value or "").split())
+    if len(text) <= limit:
+        return text
+    return text[: limit - 3].rstrip() + "..."
+
+
+def parse_activity_time(value):
+    raw = (value or "").strip()
+    if not raw:
+        return dt.datetime.min.replace(tzinfo=dt.UTC)
+    candidates = [raw]
+    if raw.endswith("Z"):
+        candidates.append(raw[:-1] + "+00:00")
+    for candidate in candidates:
+        try:
+            parsed = dt.datetime.fromisoformat(candidate)
+            if parsed.tzinfo is None:
+                parsed = parsed.replace(tzinfo=dt.UTC)
+            return parsed.astimezone(dt.UTC)
+        except ValueError:
+            pass
+    for date_format in ("%Y-%m-%d %H:%M:%S %z", "%Y-%m-%d %H:%M %z"):
+        try:
+            return dt.datetime.strptime(raw, date_format).astimezone(dt.UTC)
+        except ValueError:
+            pass
+    return dt.datetime.min.replace(tzinfo=dt.UTC)
+
+
+def activity_sort_key(action):
+    return (
+        parse_activity_time(action["occurred_at"]),
+        action["kind"],
+        action["target_url"],
+    )
+
+
+def normalize_activity_action(row):
+    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 ""
+    action["detail"] = text_preview(action["detail"])
+    return action
+
+
+def list_activity_repositories():
+    with db_connect() as conn:
+        return conn.execute(
+            """
+            SELECT repositories.*, users.username AS owner_username
+            FROM repositories
+            JOIN users ON users.id = repositories.owner_id
+            ORDER BY repositories.created_at DESC
+            """
+        ).fetchall()
+
+
+def list_commit_activity_actions(limit):
+    actions = []
+    for repo in list_activity_repositories():
+        path = repo_path(repo["owner_username"], repo["name"])
+        try:
+            commits = commit_log(path, limit=limit)
+        except (HgCommandError, OSError):
+            continue
+        for commit in commits:
+            actions.append(
+                {
+                    "kind": "commit_created",
+                    "occurred_at": commit["date"],
+                    "actor_username": "",
+                    "actor_label": commit["author"],
+                    "actor_url": "",
+                    "repo_owner_username": repo["owner_username"],
+                    "repo_name": repo["name"],
+                    "target_url": f"/{repo['owner_username']}/{repo['name']}/commits/{commit['node']}",
+                    "target_label": commit["node"],
+                    "summary": "committed",
+                    "detail": text_preview(commit["summary"]),
+                }
+            )
+    return actions
+
+
+def list_recent_actions(limit=50):
+    limit = max(1, min(int(limit), 100))
+    with db_connect() as conn:
+        rows = conn.execute(
+            """
+            SELECT *
+            FROM (
+                SELECT
+                    CASE
+                        WHEN repositories.forked_from_repo_id IS NULL THEN 'repo_created'
+                        ELSE 'repo_forked'
+                    END AS kind,
+                    repositories.created_at AS occurred_at,
+                    owner.username AS actor_username,
+                    owner.username AS repo_owner_username,
+                    repositories.name AS repo_name,
+                    '/' || owner.username || '/' || repositories.name AS target_url,
+                    owner.username || '/' || repositories.name AS target_label,
+                    CASE
+                        WHEN repositories.forked_from_repo_id IS NULL THEN 'created repository'
+                        ELSE 'forked repository'
+                    END AS summary,
+                    repositories.description AS detail
+                FROM repositories
+                JOIN users AS owner ON owner.id = repositories.owner_id
+
+                UNION ALL
+
+                SELECT
+                    'repo_starred' AS kind,
+                    repo_stars.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 AS target_url,
+                    owner.username || '/' || repositories.name AS target_label,
+                    'starred repository' AS summary,
+                    '' AS detail
+                FROM repo_stars
+                JOIN users AS actor ON actor.id = repo_stars.user_id
+                JOIN repositories ON repositories.id = repo_stars.repo_id
+                JOIN users AS owner ON owner.id = repositories.owner_id
+
+                UNION ALL
+
+                SELECT
+                    'contributor_added' AS kind,
+                    repo_contributors.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 AS target_url,
+                    owner.username || '/' || repositories.name AS target_label,
+                    'added contributor' AS summary,
+                    contributor.username AS detail
+                FROM repo_contributors
+                JOIN users AS actor ON actor.id = repo_contributors.added_by_id
+                JOIN users AS contributor ON contributor.id = repo_contributors.user_id
+                JOIN repositories ON repositories.id = repo_contributors.repo_id
+                JOIN users AS owner ON owner.id = repositories.owner_id
+
+                UNION ALL
+
+                SELECT
+                    'issue_opened' AS kind,
+                    issues.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,
+                    'opened issue' AS summary,
+                    issues.body AS detail
+                FROM issues
+                JOIN users AS actor ON actor.id = issues.author_id
+                JOIN repositories ON repositories.id = issues.repo_id
+                JOIN users AS owner ON owner.id = repositories.owner_id
+
+                UNION ALL
+
+                SELECT
+                    'issue_commented' AS kind,
+                    issue_comments.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,
+                    'commented on issue' AS summary,
+                    issue_comments.body AS detail
+                FROM issue_comments
+                JOIN users AS actor ON actor.id = issue_comments.author_id
+                JOIN issues ON issues.id = issue_comments.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,
+                    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,
+                    'opened pull request' AS summary,
+                    pull_requests.body AS detail
+                FROM pull_requests
+                JOIN users AS actor ON actor.id = pull_requests.author_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_commented' AS kind,
+                    pull_request_comments.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,
+                    'commented on pull request' AS summary,
+                    pull_request_comments.body AS detail
+                FROM pull_request_comments
+                JOIN users AS actor ON actor.id = pull_request_comments.author_id
+                JOIN pull_requests ON pull_requests.id = pull_request_comments.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,
+                    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,
+                    'merged pull request' AS summary,
+                    pull_requests.merge_node AS detail
+                FROM pull_requests
+                JOIN users AS actor ON actor.id = pull_requests.merged_by_id
+                JOIN repositories ON repositories.id = pull_requests.target_repo_id
+                JOIN users AS owner ON owner.id = repositories.owner_id
+                WHERE pull_requests.merged_at IS NOT NULL
+
+                UNION ALL
+
+                SELECT
+                    'commit_commented' AS kind,
+                    commit_comments.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 || '/commits/' || commit_comments.commit_node AS target_url,
+                    substr(commit_comments.commit_node, 1, 12) AS target_label,
+                    'commented on commit' AS summary,
+                    commit_comments.body AS detail
+                FROM commit_comments
+                JOIN users AS actor ON actor.id = commit_comments.author_id
+                JOIN repositories ON repositories.id = commit_comments.repo_id
+                JOIN users AS owner ON owner.id = repositories.owner_id
+
+                UNION ALL
+
+                SELECT
+                    'user_joined' AS kind,
+                    users.created_at AS occurred_at,
+                    users.username AS actor_username,
+                    '' AS repo_owner_username,
+                    '' AS repo_name,
+                    '/' || users.username AS target_url,
+                    '@' || users.username AS target_label,
+                    'joined' AS summary,
+                    '' AS detail
+                FROM users
+            )
+            WHERE occurred_at IS NOT NULL
+            ORDER BY occurred_at DESC
+            LIMIT ?
+            """,
+            (limit,),
+        ).fetchall()
+
+    actions = [normalize_activity_action(row) for row in rows]
+    actions.extend(list_commit_activity_actions(limit))
+    actions.sort(key=activity_sort_key, reverse=True)
+    return actions[:limit]
+
+
 def list_owned_repos(owner_id):
     with db_connect() as conn:
         return conn.execute(
@@ -1464,6 +1754,20 @@
         ).fetchall()
 
 
+def list_commit_comments(repo_id, commit_node):
+    with db_connect() as conn:
+        return conn.execute(
+            """
+            SELECT commit_comments.*, users.username AS author_username
+            FROM commit_comments
+            JOIN users ON users.id = commit_comments.author_id
+            WHERE commit_comments.repo_id = ? AND commit_comments.commit_node = ?
+            ORDER BY commit_comments.created_at ASC, commit_comments.id ASC
+            """,
+            (repo_id, commit_node),
+        ).fetchall()
+
+
 def pull_request_select_sql(where_clause):
     return f"""
         SELECT
@@ -1737,6 +2041,20 @@
     )
 
 
+def render_commit_detail(repo, path, commit, error=None, notice=None, comment_value=""):
+    return render(
+        "commit_detail.tpl",
+        repo=repo,
+        commit=commit,
+        diff=commit_diff(path, commit["node"]),
+        comments=list_commit_comments(repo["id"], commit["node"]),
+        comment_value=comment_value,
+        error=error,
+        notice=notice,
+        **repo_page_context(repo, path),
+    )
+
+
 def user_owns_repo(user, repo):
     return bool(user and repo and user["id"] == repo["owner_id"])
 
@@ -1928,7 +2246,7 @@
 
 @app.route("/")
 def index():
-    return render("index.tpl", repos=list_public_repos())
+    return render("index.tpl", actions=list_recent_actions(50))
 
 
 @app.route("/signup", method=["GET", "POST"])
@@ -2298,20 +2616,37 @@
     )
 
 
[email protected]("/<owner>/<repo_name>/commits/<node>")
[email protected]("/<owner>/<repo_name>/commits/<node>", method=["GET", "POST"])
 def repo_commit(owner, repo_name, node):
     repo = get_repo(owner, repo_name)
     if not repo:
         abort(404, "Repository not found.")
     path = repo_path(owner, repo_name)
     commit = commit_detail(path, node)
-    return render(
-        "commit_detail.tpl",
-        repo=repo,
-        commit=commit,
-        diff=commit_diff(path, node),
-        **repo_page_context(repo, path),
-    )
+    if request.method == "POST":
+        user = require_login()
+        action = request.forms.get("action")
+        if action == "comment":
+            body = request.forms.get("body", "").strip()
+            if not body:
+                return render_commit_detail(
+                    repo,
+                    path,
+                    commit,
+                    error="Comment body is required.",
+                    comment_value=body,
+                )
+            now = utcnow()
+            with db_connect() as conn:
+                conn.execute(
+                    """
+                    INSERT INTO commit_comments (repo_id, commit_node, author_id, body, created_at, updated_at)
+                    VALUES (?, ?, ?, ?, ?, ?)
+                    """,
+                    (repo["id"], commit["node"], user["id"], body[:5000], now, now),
+                )
+        redirect(f"/{owner}/{repo_name}/commits/{commit['node']}")
+    return render_commit_detail(repo, path, commit)
 
 
 @app.route("/<owner>/<repo_name>/pulls")
diff --git a/static/styles.css b/static/styles.css
--- a/static/styles.css
+++ b/static/styles.css
@@ -100,6 +100,10 @@
 .meta-list { display: grid; grid-template-columns: 8rem 1fr; gap: .35rem 1rem; }
 .meta-list dt { color: #666; }
 .meta-list dd { margin: 0; overflow-x: auto; }
+.activity-feed { display: grid; gap: 1rem; padding-left: 0; list-style: none; }
+.activity-feed li { padding-bottom: 1rem; border-bottom: 1px solid #eee; }
+.activity-title, .activity-detail { margin-bottom: .25rem; }
+.activity-detail { color: #333; }
 .comment-list { display: grid; gap: 1rem; }
 .comment { border-bottom: 1px solid #eee; }
 .diff { max-height: 70vh; }
diff --git a/templates/commit_detail.tpl b/templates/commit_detail.tpl
--- a/templates/commit_detail.tpl
+++ b/templates/commit_detail.tpl
@@ -27,6 +27,34 @@
 </section>
 
 <section class="panel">
+  <h2>Comments</h2>
+  % if comments:
+    <div class="comment-list">
+      % for comment in comments:
+        <article class="comment">
+            <p><strong><a href="/{{comment["author_username"]}}">@{{comment["author_username"]}}</a>:</strong> {{!render_markdown_links(comment["body"])}} <small class="muted">{{comment["created_at"]}}</small></p>
+        </article>
+      % end
+    </div>
+  % else:
+    <p class="empty">No comments yet.</p>
+  % end
+
+  % if user:
+    <form method="post">
+      <input type="hidden" name="action" value="comment">
+      <label>
+        Add a comment
+        <textarea name="body" rows="5">{{comment_value}}</textarea>
+      </label>
+      <button class="button" type="submit">Comment</button>
+    </form>
+  % else:
+    <p><a href="/login?next=/{{repo['owner_username']}}/{{repo['name']}}/commits/{{commit['node']}}">Log in to comment</a></p>
+  % end
+</section>
+
+<section class="panel">
   <h2>Diff</h2>
   % if diff:
     <pre class="diff"><code class="language-diff">{{diff}}</code></pre>
diff --git a/templates/index.tpl b/templates/index.tpl
--- a/templates/index.tpl
+++ b/templates/index.tpl
@@ -1,31 +1,41 @@
-% rebase("base.tpl", title="HgHost", user=user, error=error, notice=notice)
-
-<section class="hero">
-  <div>
-    <p class="eyebrow">Mercurial hosting</p>
-    <h1>Public hg repositories for open source software</h1>
-    % if not user:
-      <div class="hero-actions">
-        <a class="button" href="/signup">Create an account</a>
-        <a class="button secondary" href="/login">Log in</a>
-      </div>
-    % end
-  </div>
-</section>
+% rebase("base.tpl", title="HgLab activity", user=user, error=error, notice=notice)
 
 <section class="panel">
   <div class="panel-heading">
-    <h2>Popular repos</h2>
+    <div>
+      <p class="eyebrow">Recent activity</p>
+      <h1>Feed</h1>
+    </div>
   </div>
-  % if repos:
-      % for repo in repos:
-        <a style="color:black;" href="/{{repo['owner_username']}}/{{repo['name']}}"><strong>{{repo["owner_username"]}}/{{repo["name"]}}</strong></a>
-        <br>
-        {{!render_markdown_links(repo["description"]) or "No description yet."}}
-        <br>
-        <br>
+
+  % if actions:
+    <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>
   % else:
-    <p class="empty">No repositories yet.</p>
+    <p class="empty">No activity yet.</p>
   % end
 </section>