patx/gitman

added search for issues and PRs

Commit 374fb2c · patx · 2026-05-10T19:16:50Z

Changeset
374fb2cc0ba83c51bcaa67b9886dea2d07a71394
Parents
dbdec61c311dcee264c9ecf606b02fdb9fbc37d6

View source at this commit

Comments

No comments yet.

Log in to comment

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&amp;page=2"' in first.text
+    assert 'href="/alice/demo/issues?q=memory&amp;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&amp;page=2"' in first.text
+    assert 'href="/alice/demo/pulls?q=feature&amp;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")