Fork of patx/hglab.
tokenmess/hglab
added comments on commits, changed the index page to a recent activity feed
Commit e659e2eaf657 · Harrison Erd · 2026-05-04 17:44 -0400
Comments
No comments yet.
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>