patx/gitman
added search for issues and PRs
Commit 374fb2c · patx · 2026-05-10T19:16:50Z
Comments
No comments yet.
Diff
diff --git a/app.py b/app.py
index e23c45d..5e9d1ad 100644
--- a/app.py
+++ b/app.py
@@ -96,6 +96,8 @@ REF_PAGE_SIZE = env_int("GITMAN_REF_PAGE_SIZE", 50, minimum=1)
PROFILE_REPO_PAGE_SIZE = env_int("GITMAN_PROFILE_REPO_PAGE_SIZE", 25, minimum=1)
REPO_SEARCH_PAGE_SIZE = env_int("GITMAN_REPO_SEARCH_PAGE_SIZE", 25, minimum=1)
REF_SEARCH_PAGE_SIZE = env_int("GITMAN_REF_SEARCH_PAGE_SIZE", 50, minimum=1)
+ISSUE_PAGE_SIZE = env_int("GITMAN_ISSUE_PAGE_SIZE", 25, minimum=1)
+PULL_REQUEST_PAGE_SIZE = env_int("GITMAN_PULL_REQUEST_PAGE_SIZE", 25, minimum=1)
MAX_PAGE_SIZE = env_int("GITMAN_MAX_PAGE_SIZE", 100, minimum=1)
GIT_BINARY = os.environ.get("GITMAN_GIT_BINARY", "git")
PAGES_DOMAIN = os.environ.get("GITMAN_PAGES_DOMAIN", "gitman.io").strip().lower().rstrip(".")
@@ -904,6 +906,7 @@ def render(template_name, **context):
context.setdefault("url_with_ref", url_with_ref)
context.setdefault("current_url_with_ref", current_url_with_ref)
context.setdefault("current_url_with_page", current_url_with_page)
+ context.setdefault("current_url_with_params", current_url_with_params)
context.setdefault("ref_option_label", ref_option_label)
return template(template_name, **context)
@@ -986,6 +989,20 @@ def current_url_with_page(page):
return request.path + (f"?{encoded}" if encoded else "")
+def current_url_with_params(**updates):
+ params = [
+ (key, value)
+ for key, value in request.query.allitems()
+ if key not in updates and key != "page"
+ ]
+ for key, value in updates.items():
+ if value is None:
+ continue
+ params.append((key, str(value)))
+ encoded = urlencode(params)
+ return request.path + (f"?{encoded}" if encoded else "")
+
+
def repo_path(owner_username, repo_name):
path = REPO_ROOT / owner_username / repo_name
root = REPO_ROOT.resolve()
@@ -3672,14 +3689,55 @@ def pull_request_counts(repo_id):
return counts
-def list_issues(repo_id, status="open"):
- if status not in {"open", "closed", "all"}:
- status = "open"
+def bounded_search_query(query):
+ return (query or "").strip()[:100]
+
+
+def escape_like_term(value):
+ return (value or "").replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
+
+
+def like_pattern(value):
+ return f"%{escape_like_term(value)}%"
+
+
+def normalized_issue_status(status):
+ return status if status in {"open", "closed", "all"} else "open"
+
+
+def normalized_pull_request_status(status):
+ return status if status in {"open", "closed", "merged", "all"} else "open"
+
+
+def issue_filters(repo_id, status="open", query=""):
+ status = normalized_issue_status(status)
+ query = bounded_search_query(query)
where = "WHERE issues.repo_id = ?"
params = [repo_id]
if status != "all":
where += " AND issues.status = ?"
params.append(status)
+ if query:
+ text_pattern = like_pattern(query)
+ number_pattern = like_pattern(query[1:] if query.startswith("#") else query)
+ where += """
+ AND (
+ issues.title LIKE ? ESCAPE '\\'
+ OR issues.body LIKE ? ESCAPE '\\'
+ OR users.username LIKE ? ESCAPE '\\'
+ OR CAST(issues.number AS TEXT) LIKE ? ESCAPE '\\'
+ )
+ """
+ params.extend([text_pattern, text_pattern, text_pattern, number_pattern])
+ return where, params
+
+
+def list_issues(repo_id, status="open", query="", limit=None, offset=0):
+ where, params = issue_filters(repo_id, status, query)
+ pagination = ""
+ if limit is not None:
+ pagination = "LIMIT ? OFFSET ?"
+ params.extend([limit, offset])
with db_connect() as conn:
return conn.execute(
f"""
@@ -3688,11 +3746,68 @@ def list_issues(repo_id, status="open"):
JOIN users ON users.id = issues.author_id
{where}
ORDER BY issues.updated_at DESC, issues.number DESC
+ {pagination}
""",
params,
).fetchall()
+def issue_result_count(repo_id, status="open", query=""):
+ where, params = issue_filters(repo_id, status, query)
+ with db_connect() as conn:
+ return conn.execute(
+ f"""
+ SELECT COUNT(*)
+ FROM issues
+ JOIN users ON users.id = issues.author_id
+ {where}
+ """,
+ params,
+ ).fetchone()[0]
+
+
+def pull_request_filters(repo_id, status="open", query=""):
+ status = normalized_pull_request_status(status)
+ query = bounded_search_query(query)
+ where = "WHERE pull_requests.target_repo_id = ?"
+ params = [repo_id]
+ if status != "all":
+ where += " AND pull_requests.status = ?"
+ params.append(status)
+ if query:
+ text_pattern = like_pattern(query)
+ number_pattern = like_pattern(query[1:] if query.startswith("#") else query)
+ where += """
+ AND (
+ pull_requests.title LIKE ? ESCAPE '\\'
+ OR pull_requests.body LIKE ? ESCAPE '\\'
+ OR author.username LIKE ? ESCAPE '\\'
+ OR source_owner.username LIKE ? ESCAPE '\\'
+ OR source_repo.name LIKE ? ESCAPE '\\'
+ OR target_owner.username LIKE ? ESCAPE '\\'
+ OR target_repo.name LIKE ? ESCAPE '\\'
+ OR pull_requests.source_ref_name LIKE ? ESCAPE '\\'
+ OR pull_requests.target_ref_name LIKE ? ESCAPE '\\'
+ OR CAST(pull_requests.number AS TEXT) LIKE ? ESCAPE '\\'
+ )
+ """
+ params.extend(
+ [
+ text_pattern,
+ text_pattern,
+ text_pattern,
+ text_pattern,
+ text_pattern,
+ text_pattern,
+ text_pattern,
+ text_pattern,
+ text_pattern,
+ number_pattern,
+ ]
+ )
+ return where, params
+
+
def get_issue(repo_id, number):
with db_connect() as conn:
return conn.execute(
@@ -3769,21 +3884,42 @@ def pull_request_select_sql(where_clause):
"""
-def list_pull_requests(repo_id, status="open"):
- if status not in {"open", "closed", "merged", "all"}:
- status = "open"
- where = "WHERE pull_requests.target_repo_id = ?"
- params = [repo_id]
- if status != "all":
- where += " AND pull_requests.status = ?"
- params.append(status)
+def list_pull_requests(repo_id, status="open", query="", limit=None, offset=0):
+ where, params = pull_request_filters(repo_id, status, query)
+ pagination = ""
+ if limit is not None:
+ pagination = "LIMIT ? OFFSET ?"
+ params.extend([limit, offset])
with db_connect() as conn:
return conn.execute(
- pull_request_select_sql(where) + " ORDER BY pull_requests.updated_at DESC, pull_requests.number DESC",
+ pull_request_select_sql(where)
+ + f"""
+ ORDER BY pull_requests.updated_at DESC, pull_requests.number DESC
+ {pagination}
+ """,
params,
).fetchall()
+def pull_request_result_count(repo_id, status="open", query=""):
+ where, params = pull_request_filters(repo_id, status, query)
+ with db_connect() as conn:
+ return conn.execute(
+ f"""
+ SELECT COUNT(*)
+ FROM pull_requests
+ JOIN users AS author ON author.id = pull_requests.author_id
+ JOIN repositories AS source_repo ON source_repo.id = pull_requests.source_repo_id
+ JOIN users AS source_owner ON source_owner.id = source_repo.owner_id
+ JOIN repositories AS target_repo ON target_repo.id = pull_requests.target_repo_id
+ JOIN users AS target_owner ON target_owner.id = target_repo.owner_id
+ LEFT JOIN users AS merged_by ON merged_by.id = pull_requests.merged_by_id
+ {where}
+ """,
+ params,
+ ).fetchone()[0]
+
+
def get_pull_request(repo_id, number):
with db_connect() as conn:
return conn.execute(
@@ -5037,14 +5173,26 @@ def repo_pull_requests(owner, repo_name):
if not repo:
abort(404, "Repository not found.")
path = repo_path(owner, repo_name)
- status = request.query.get("status", "open")
+ status = normalized_pull_request_status(request.query.get("status", "open"))
+ query = bounded_search_query(request.query.get("q", ""))
+ page = request_page()
+ per_page = PULL_REQUEST_PAGE_SIZE
+ total = pull_request_result_count(repo["id"], status, query)
counts = pull_request_counts(repo["id"])
return render(
"pull_requests.tpl",
repo=repo,
- pull_requests=list_pull_requests(repo["id"], status),
- status=status if status in {"open", "closed", "merged", "all"} else "open",
+ pull_requests=list_pull_requests(
+ repo["id"],
+ status,
+ query=query,
+ limit=per_page,
+ offset=page_offset(page, per_page),
+ ),
+ status=status,
+ q=query,
counts=counts,
+ pagination=pagination_metadata(page, per_page, total=total),
**repo_page_context(repo, path),
)
@@ -5224,15 +5372,27 @@ def repo_issues(owner, repo_name):
if not repo:
abort(404, "Repository not found.")
path = repo_path(owner, repo_name)
- status = request.query.get("status", "open")
+ status = normalized_issue_status(request.query.get("status", "open"))
+ query = bounded_search_query(request.query.get("q", ""))
+ page = request_page()
+ per_page = ISSUE_PAGE_SIZE
+ total = issue_result_count(repo["id"], status, query)
counts = issue_counts(repo["id"])
context = repo_page_context(repo, path)
return render(
"issues.tpl",
repo=repo,
- issues=list_issues(repo["id"], status),
- status=status if status in {"open", "closed", "all"} else "open",
+ issues=list_issues(
+ repo["id"],
+ status,
+ query=query,
+ limit=per_page,
+ offset=page_offset(page, per_page),
+ ),
+ status=status,
+ q=query,
counts=counts,
+ pagination=pagination_metadata(page, per_page, total=total),
**context,
)
diff --git a/static/styles.css b/static/styles.css
index 3a53fb7..1e50688 100644
--- a/static/styles.css
+++ b/static/styles.css
@@ -275,6 +275,16 @@ button, .button {
}
.ref-picker-link.active { color: var(--text); font-weight: 700; text-decoration: none; }
.filters .active, .tabs .active { color: var(--text); font-weight: 700; text-decoration: none; }
+.list-search {
+ display: inline-flex;
+ gap: .4rem;
+ align-items: center;
+ flex-wrap: wrap;
+}
+.list-search input[type="search"] {
+ width: 18rem;
+ max-width: 100%;
+}
.hero, .repo-tabs { margin-bottom: 1.5rem; }
.eyebrow, .muted, .empty, .nav-user, .repo-card small, .issue-list span, .commit-list span, .file-list span, .clean-list span, .file-kind { color: var(--muted); }
.repo-list, .stack, form, .clean-list, .commit-list, .file-list, .issue-list { display: grid; gap: .75rem; }
diff --git a/templates/issues.tpl b/templates/issues.tpl
index 27ab2ab..b8b61d8 100644
--- a/templates/issues.tpl
+++ b/templates/issues.tpl
@@ -1,4 +1,6 @@
% rebase("base.tpl", title=repo["owner_username"] + "/" + repo["name"] + " issues", user=user, error=error, notice=notice)
+% q = get("q", "")
+% issue_scope = "issues" if status == "all" else status + " issues"
<section class="repo-header slim">
<div>
@@ -14,9 +16,18 @@
<div class="panel-heading">
<h2>Issues</h2>
<div class="filters">
- <a class="{{'active' if status == 'open' else ''}}" href="?status=open">Open ({{counts["open"]}})</a>
- <a class="{{'active' if status == 'closed' else ''}}" href="?status=closed">Closed ({{counts["closed"]}})</a>
- <a class="{{'active' if status == 'all' else ''}}" href="?status=all">All</a>
+ <a class="{{'active' if status == 'open' else ''}}" href="{{current_url_with_params(status='open')}}">Open ({{counts["open"]}})</a>
+ <a class="{{'active' if status == 'closed' else ''}}" href="{{current_url_with_params(status='closed')}}">Closed ({{counts["closed"]}})</a>
+ <a class="{{'active' if status == 'all' else ''}}" href="{{current_url_with_params(status='all')}}">All</a>
+ <form class="list-search" action="/{{repo['owner_username']}}/{{repo['name']}}/issues" method="get" role="search">
+ <input type="hidden" name="status" value="{{status}}">
+ <label class="sr-only" for="issue-search-input">Search issues</label>
+ <input id="issue-search-input" type="search" name="q" value="{{q}}" placeholder="Search issues" autocomplete="off">
+ <button type="submit">Search</button>
+ % if q:
+ <a href="{{current_url_with_params(q=None)}}">Clear</a>
+ % end
+ </form>
% if user:
<a href="/{{repo['owner_username']}}/{{repo['name']}}/issues/new">New issue</a>
% else:
@@ -25,14 +36,20 @@
</div>
</div>
% if issues:
- <ul class="issue-list">
+ <ul class="issue-list" data-paginated-list>
% for issue in issues:
<li>
<a href="/{{repo['owner_username']}}/{{repo['name']}}/issues/{{issue['number']}}">#{{issue["number"]}} {{issue["title"]}}</a> <span>({{issue["status"]}})</span>
</li>
% end
</ul>
+ % include("pagination.tpl", pagination=pagination)
% else:
- <p class="empty">No {{status}} issues.</p>
+ % if q:
+ <p class="empty">No {{issue_scope}} matching "{{q}}".</p>
+ % else:
+ <p class="empty">No {{issue_scope}}.</p>
+ % end
+ % include("pagination.tpl", pagination=pagination)
% end
</section>
diff --git a/templates/pull_requests.tpl b/templates/pull_requests.tpl
index 485f2f8..f92461f 100644
--- a/templates/pull_requests.tpl
+++ b/templates/pull_requests.tpl
@@ -1,4 +1,6 @@
% rebase("base.tpl", title=repo["owner_username"] + "/" + repo["name"] + " pull requests", user=user, error=error, notice=notice)
+% q = get("q", "")
+% pull_request_scope = "pull requests" if status == "all" else status + " pull requests"
<section class="repo-header slim">
<div>
@@ -14,10 +16,19 @@
<div class="panel-heading">
<h2>Pull requests</h2>
<div class="filters">
- <a class="{{'active' if status == 'open' else ''}}" href="?status=open">Open ({{counts["open"]}})</a>
- <a class="{{'active' if status == 'merged' else ''}}" href="?status=merged">Merged ({{counts["merged"]}})</a>
- <a class="{{'active' if status == 'closed' else ''}}" href="?status=closed">Closed ({{counts["closed"]}})</a>
- <a class="{{'active' if status == 'all' else ''}}" href="?status=all">All</a>
+ <a class="{{'active' if status == 'open' else ''}}" href="{{current_url_with_params(status='open')}}">Open ({{counts["open"]}})</a>
+ <a class="{{'active' if status == 'merged' else ''}}" href="{{current_url_with_params(status='merged')}}">Merged ({{counts["merged"]}})</a>
+ <a class="{{'active' if status == 'closed' else ''}}" href="{{current_url_with_params(status='closed')}}">Closed ({{counts["closed"]}})</a>
+ <a class="{{'active' if status == 'all' else ''}}" href="{{current_url_with_params(status='all')}}">All</a>
+ <form class="list-search" action="/{{repo['owner_username']}}/{{repo['name']}}/pulls" method="get" role="search">
+ <input type="hidden" name="status" value="{{status}}">
+ <label class="sr-only" for="pull-request-search-input">Search pull requests</label>
+ <input id="pull-request-search-input" type="search" name="q" value="{{q}}" placeholder="Search pull requests" autocomplete="off">
+ <button type="submit">Search</button>
+ % if q:
+ <a href="{{current_url_with_params(q=None)}}">Clear</a>
+ % end
+ </form>
% if user:
<a href="/{{repo['owner_username']}}/{{repo['name']}}/pulls/new">New pull request</a>
% else:
@@ -26,7 +37,7 @@
</div>
</div>
% if pull_requests:
- <ul class="issue-list">
+ <ul class="issue-list" data-paginated-list>
% for pr in pull_requests:
<li>
<a href="/{{repo['owner_username']}}/{{repo['name']}}/pulls/{{pr['number']}}">#{{pr["number"]}} {{pr["title"]}}</a>
@@ -34,7 +45,13 @@
</li>
% end
</ul>
+ % include("pagination.tpl", pagination=pagination)
% else:
- <p class="empty">No {{status}} pull requests.</p>
+ % if q:
+ <p class="empty">No {{pull_request_scope}} matching "{{q}}".</p>
+ % else:
+ <p class="empty">No {{pull_request_scope}}.</p>
+ % end
+ % include("pagination.tpl", pagination=pagination)
% end
</section>
diff --git a/tests/test_app.py b/tests/test_app.py
index 6c684ff..660514a 100644
--- a/tests/test_app.py
+++ b/tests/test_app.py
@@ -1420,6 +1420,55 @@ def test_issue_queries_count_filter_and_order_comments(isolated_app):
assert [comment["body"] for comment in isolated_app.list_issue_comments(issue_id)] == ["first", "second"]
+def test_issue_list_searches_and_paginates_results(isolated_app, monkeypatch):
+ monkeypatch.setattr(gitman, "ISSUE_PAGE_SIZE", 2)
+ owner = create_user("alice")
+ isolated_app.create_repository(owner, "demo", "")
+ repo = isolated_app.get_repo("alice", "demo")
+
+ with isolated_app.db_connect() as conn:
+ for number in range(1, 4):
+ timestamp = f"2030-01-01T00:00:0{number}Z"
+ conn.execute(
+ """
+ INSERT INTO issues (repo_id, author_id, number, title, body, status, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, 'open', ?, ?)
+ """,
+ (
+ repo["id"],
+ owner["id"],
+ number,
+ f"Memory leak {number}",
+ "Only this body matches the issue search.",
+ timestamp,
+ timestamp,
+ ),
+ )
+ conn.execute(
+ """
+ INSERT INTO issues (repo_id, author_id, number, title, body, status, created_at, updated_at)
+ VALUES (?, ?, 4, 'Unrelated report', '', 'open', ?, ?)
+ """,
+ (repo["id"], owner["id"], "2030-01-01T00:00:04Z", "2030-01-01T00:00:04Z"),
+ )
+ client = WsgiClient(isolated_app.app)
+
+ first = client.get("/alice/demo/issues?q=memory")
+ second = client.get("/alice/demo/issues?q=memory&page=2")
+ number_search = client.get("/alice/demo/issues?q=%231")
+
+ assert "#3 Memory leak 3" in first.text
+ assert "#2 Memory leak 2" in first.text
+ assert "#1 Memory leak 1" not in first.text
+ assert "Unrelated report" not in first.text
+ assert 'data-paginated-list' in first.text
+ assert 'data-next-url="/alice/demo/issues?q=memory&page=2"' in first.text
+ assert 'href="/alice/demo/issues?q=memory&status=closed"' in first.text
+ assert ">Next</a>" not in first.text
+ assert "#1 Memory leak 1" in second.text
+ assert "#1 Memory leak 1" in number_search.text
+
+
def test_git_read_helpers_return_files_readme_commits_and_default_ref(isolated_app):
owner = create_user("alice")
isolated_app.create_repository(owner, "demo", "")
@@ -2191,6 +2240,78 @@ def test_bottle_issue_routes_create_comment_close_and_reopen(isolated_app):
assert isolated_app.get_issue(isolated_app.get_repo("alice", "demo")["id"], 1)["status"] == "open"
+def test_pull_request_list_searches_and_paginates_results(isolated_app, monkeypatch):
+ monkeypatch.setattr(gitman, "PULL_REQUEST_PAGE_SIZE", 2)
+ owner = create_user("alice")
+ author = create_user("bob")
+ isolated_app.create_repository(owner, "demo", "")
+ isolated_app.create_repository(author, "demo-fork", "")
+ target_repo = isolated_app.get_repo("alice", "demo")
+ source_repo = isolated_app.get_repo("bob", "demo-fork")
+
+ with isolated_app.db_connect() as conn:
+ for number in range(1, 4):
+ timestamp = f"2030-01-01T00:00:0{number}Z"
+ conn.execute(
+ """
+ INSERT INTO pull_requests (
+ target_repo_id, source_repo_id, author_id, number, title, body, status,
+ base_node, source_node, target_ref_type, target_ref_name, source_ref_type, source_ref_name,
+ created_at, updated_at
+ )
+ VALUES (?, ?, ?, ?, ?, ?, 'open', ?, ?, 'branch', 'main', 'branch', ?, ?, ?)
+ """,
+ (
+ target_repo["id"],
+ source_repo["id"],
+ author["id"],
+ number,
+ f"Feature search {number}",
+ "Please review the feature search work.",
+ "a" * 40,
+ "b" * 40,
+ f"feature/search-{number}",
+ timestamp,
+ timestamp,
+ ),
+ )
+ conn.execute(
+ """
+ INSERT INTO pull_requests (
+ target_repo_id, source_repo_id, author_id, number, title, body, status,
+ base_node, source_node, target_ref_type, target_ref_name, source_ref_type, source_ref_name,
+ created_at, updated_at
+ )
+ VALUES (?, ?, ?, 4, 'Maintenance cleanup', '', 'open', ?, ?, 'branch', 'main', 'branch', 'chore', ?, ?)
+ """,
+ (
+ target_repo["id"],
+ source_repo["id"],
+ author["id"],
+ "a" * 40,
+ "b" * 40,
+ "2030-01-01T00:00:04Z",
+ "2030-01-01T00:00:04Z",
+ ),
+ )
+ client = WsgiClient(isolated_app.app)
+
+ first = client.get("/alice/demo/pulls?q=feature")
+ second = client.get("/alice/demo/pulls?q=feature&page=2")
+ ref_search = client.get("/alice/demo/pulls?q=search-1")
+
+ assert "#3 Feature search 3" in first.text
+ assert "#2 Feature search 2" in first.text
+ assert "#1 Feature search 1" not in first.text
+ assert "Maintenance cleanup" not in first.text
+ assert 'data-paginated-list' in first.text
+ assert 'data-next-url="/alice/demo/pulls?q=feature&page=2"' in first.text
+ assert 'href="/alice/demo/pulls?q=feature&status=merged"' in first.text
+ assert ">Next</a>" not in first.text
+ assert "#1 Feature search 1" in second.text
+ assert "#1 Feature search 1" in ref_search.text
+
+
def test_bottle_pull_request_routes_create_comment_forbid_and_merge(isolated_app):
owner = create_user("alice")
author = create_user("bob")