Searching Issues with Boolean Logic and Nested Queries

GitHub Issues search has long been a flat, implicit-AND affair: every term and field in a query narrowed results further. That limitation is now gone. With the advanced search syntax shipped in April, you can combine logical AND/OR operators with parenthetical nesting across every issue field. A query like is:issue state:open author:rileybroughten (type:Bug OR type:Epic) returns open issues authored by rileybroughten that are of type Bug or Epic.

Screenshot of an Issues search query involving the logical OR operator.

Under the hood, this meant replacing the search module that parses query strings and maps them into Elasticsearch queries. The old IssuesQuery module became a new ConditionalIssuesQuery module capable of handling nested queries while preserving support for all existing query formats.

The architecture of the Issues search system (and the changes needed to build this feature).

A search request passes through three stages on its way to results: parse, query, and normalize. The rewrite touched the first two stages, while the normalize step—mapping Elasticsearch JSON results into Ruby objects and pruning stale records—remained unchanged.

From Flat Lists to Abstract Syntax Trees

The parse stage turns raw user input into an intermediate structure. Inputs contain free-text query terms (like models) and search filters (like assignee:Deborah-Digges). With only flat queries supported, a parse into a simple list of terms and filters sufficed.

Nested queries are recursive by nature, so a list no longer cuts it. The parser now uses the parslet library to build an Abstract Syntax Tree (AST). The grammar—a Parsing Expression Grammar—covers both the legacy query shape and the new nested syntax, which is what guarantees backward compatibility.

class Parser < Parslet::Parser
  rule(:space)  { match[" "].repeat(1) }
  rule(:space?) { space.maybe }

  rule(:lparen) { str("(") >> space? }
  rule(:rparen) { str(")") >> space? }

  rule(:and_operator) { str("and") >> space? }
  rule(:or_operator)  { str("or")  >> space? }

  rule(:var) { str("var") >> match["0-9"].repeat(1).as(:var) >> space? }

  # The primary rule deals with parentheses.
  rule(:primary) { lparen >> or_operation >> rparen | var }

  # Note that following rules are both right-recursive.
  rule(:and_operation) { 
    (primary.as(:left) >> and_operator >> 
      and_operation.as(:right)).as(:and) | 
    primary }
    
  rule(:or_operation)  { 
    (and_operation.as(:left) >> or_operator >> 
      or_operation.as(:right)).as(:or) | 
    and_operation }

  # We start at the lowest precedence rule.
  root(:or_operation)
end

The search string is:issue AND (author:deborah-digges OR author:monalisa) parses into this AST:

{
  "root": {
    "and": {
      "left": {
        "filter_term": {
          "attribute": "is",
          "value": [
            {
              "filter_value": "issue"
            }
          ]
        }
      },
      "right": {
        "or": {
          "left": {
            "filter_term": {
              "attribute": "author",
              "value": [
                {
                  "filter_value": "deborah-digges"
                }
              ]
            }
          },
          "right": {
            "filter_term": {
              "attribute": "author",
              "value": [
                {
                  "filter_value": "monalisa"
                }
              ]
            }
          }
        }
      }
    }
  }
}

Generating Elasticsearch Queries from the AST

Query generation previously did a linear mapping: each filter term had a corresponding class that knew how to emit its slice of an Elasticsearch query document. During generation, the correct class for each filter was invoked to assemble the full document.

Now, query generation recursively walks the AST. The mapping is nearly one-to-one: AND, OR, and NOT correspond to Elasticsearch's boolean query must, should, and should_not clauses. The existing per-filter building blocks are reused at each level of the recursion.

Search Architecture

For the example above, the AST becomes this Elasticsearch query document:

{
  "query": {
    "bool": {
      "must": [
        {
          "bool": {
            "must": [
              {
                "bool": {
                  "must": {
                    "prefix": {
                      "_index": "issues"
                    }
                  }
                }
              },
              {
                "bool": {
                  "should": {
                    "terms": {
                      "author_id": [
                        "<DEBORAH_DIGGES_AUTHOR_ID>",
                        "<MONALISA_AUTHOR_ID>"
                      ]
                    }
                  }
                }
              }
            ]
          }
        }
      ]
    }
    // SOME TERMS OMITTED FOR BREVITY
  }
}

Rollout Challenges: Compatibility, Performance, UX, Risk

Issues search handles nearly 2,000 queries per second—roughly 160 million queries a day. Introducing a nested-query engine into that pipeline without regressions required deliberate validation at every step.

Backward Compatibility

Search queries get bookmarked, shared, and embedded in team documentation. Breaking them was not an option. Before any user saw the new system, the team ran the new module against the existing search module's full unit and integration test suite. GraphQL and REST API contract tests were run with the feature flag both on and off.

The team also dark-shipped the feature: for 1% of live issue searches, the user's query ran against both the old and new systems in a background job, and any difference in the number of results was logged. Analyzing those differences surfaced bugs and missed edge cases before they could reach users. The "number of results" metric was the first definition of "different," on the theory that a user would notice if a query run twice within a second returned a different count.

Performance

Nested queries were expected to consume more backend resources than simple flat ones. The team needed a realistic baseline for the new queries while proving that existing simple queries didn't regress. Using scientist, GitHub's Ruby framework for refactoring critical paths, they ran equivalent queries against both systems for the same 1% sample of searches and compared performance.

User Experience

Adding complexity shouldn't make the feature harder to use. Product and design collaborated to keep usability in check by capping nesting depth at five levels—a sweet spot identified in customer interviews—and by highlighting AND/OR keywords in queries. The autocomplete for filter terms that users had in flat queries carries over to the nested syntax.

Managing Risk

The rollout deliberately limited the blast radius. The new system first went live only in the GraphQL API and the repository Issues tab, giving the team room to collect feedback before extending it to the Issues dashboard and the REST API. Internally, the feature was tested by the team throughout development, then rolled out to all GitHub employees, and finally to trusted partners for initial user feedback.

The result: nested boolean queries are no longer confined to label fields—they work across all issue fields, and the searches you've already saved continue to work exactly as they did.