Support (most of) gerrit search syntax

Adds a parser that compiles gerrit search queries into sqlalchemy
filters to support the gerrit search syntax both in arbitrary
searches (ctrl-o) as well as dashboards.

Change-Id: Ibc8e5c180b0ca16727938725cfe6380ba7315205
This commit is contained in:
James E. Blair 2014-07-23 15:52:50 -07:00
parent 42419904a8
commit 6181e4f9bf
8 changed files with 512 additions and 28 deletions

View File

@ -27,6 +27,7 @@ from gertty import config
from gertty import gitrepo from gertty import gitrepo
from gertty import mywid from gertty import mywid
from gertty import sync from gertty import sync
from gertty import search
from gertty.view import change_list as view_change_list from gertty.view import change_list as view_change_list
from gertty.view import project_list as view_project_list from gertty.view import project_list as view_project_list
from gertty.view import change as view_change from gertty.view import change as view_change
@ -106,6 +107,7 @@ class App(object):
level=level) level=level)
self.log = logging.getLogger('gertty.App') self.log = logging.getLogger('gertty.App')
self.log.debug("Starting") self.log.debug("Starting")
self.search = search.SearchCompiler(self)
self.db = db.Database(self) self.db = db.Database(self)
self.sync = sync.Sync(self) self.sync = sync.Sync(self)
@ -209,14 +211,12 @@ class App(object):
def _syncOneChangeFromQuery(self, query): def _syncOneChangeFromQuery(self, query):
number = changeid = None number = changeid = None
if query.startswith("number:"): if query.startswith("change:"):
number = query.split(':')[1].strip() number = query.split(':')[1].strip()
try: try:
number = int(number) number = int(number)
except Exception: except ValueError:
pass changeid = query.split(':')[1].strip()
if query.startswith("changeid:"):
changeid = query.split(':')[1].strip()
if not (number or changeid): if not (number or changeid):
return return
with self.db.getSession() as session: with self.db.getSession() as session:
@ -243,14 +243,17 @@ class App(object):
if change_key is None: if change_key is None:
raise Exception('Change is not in local database.') raise Exception('Change is not in local database.')
def search(self, query): def doSearch(self, query):
self.log.debug("Search query: %s" % query) self.log.debug("Search query: %s" % query)
try: try:
self._syncOneChangeFromQuery(query) self._syncOneChangeFromQuery(query)
except Exception as e: except Exception as e:
return self.error(e.message) return self.error(e.message)
with self.db.getSession() as session: with self.db.getSession() as session:
changes = session.getChanges(query) try:
changes = session.getChanges(query)
except gertty.search.SearchSyntaxError as e:
return self.error(e.message)
change_key = None change_key = None
if len(changes) == 1: if len(changes) == 1:
change_key = changes[0].key change_key = changes[0].key
@ -261,7 +264,7 @@ class App(object):
view = view_change_list.ChangeListView(self, query) view = view_change_list.ChangeListView(self, query)
self.changeScreen(view) self.changeScreen(view)
except gertty.view.DisplayError as e: except gertty.view.DisplayError as e:
self.error(e.message) return self.error(e.message)
def searchDialog(self): def searchDialog(self):
dialog = SearchDialog() dialog = SearchDialog()
@ -275,10 +278,10 @@ class App(object):
self.backScreen() self.backScreen()
query = dialog.entry.edit_text query = dialog.entry.edit_text
try: try:
query = 'number:%s' % int(query) query = 'change:%s' % int(query)
except Exception: except ValueError:
pass pass
self.search(query) self.doSearch(query)
def error(self, message): def error(self, message):
dialog = mywid.MessageDialog('Error', message) dialog = mywid.MessageDialog('Error', message)

View File

@ -374,6 +374,7 @@ class DatabaseSession(object):
def __init__(self, database): def __init__(self, database):
self.database = database self.database = database
self.session = database.session self.session = database.session
self.search = database.app.search
def __enter__(self): def __enter__(self):
self.database.lock.acquire() self.database.lock.acquire()
@ -443,25 +444,13 @@ class DatabaseSession(object):
return None return None
def getChanges(self, query, unreviewed=False): def getChanges(self, query, unreviewed=False):
#TODO(jeblair): use a real parser that supports the full gerrit query syntax self.database.log.debug("Search query: %s" % query)
q = self.session().query(Change) search_filter = self.search.parse(query)
for term in query.split(): q = self.session().query(Change).filter(search_filter).order_by(change_table.c.number)
key, data = term.split(':')
if key == 'number':
q = q.filter(change_table.c.number==data)
elif key == 'changeid':
q = q.filter(change_table.c.change_id==data)
elif key == 'project_key':
q = q.filter(change_table.c.project_key==data)
elif key == 'status':
if data == 'open':
q = q.filter(change_table.c.status.notin_(['MERGED', 'ABANDONED']))
else:
q = q.filter(change_table.c.status==data)
if unreviewed: if unreviewed:
q = q.filter(change_table.c.hidden==False, change_table.c.reviewed==False) q = q.filter(change_table.c.hidden==False, change_table.c.reviewed==False)
try: try:
return q.order_by(change_table.c.number).all() return q.all()
except sqlalchemy.orm.exc.NoResultFound: except sqlalchemy.orm.exc.NoResultFound:
return [] return []

26
gertty/search/__init__.py Normal file
View File

@ -0,0 +1,26 @@
# Copyright 2014 Hewlett-Packard Development Company, L.P.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
from gertty.search import tokenizer, parser
class SearchSyntaxError(Exception):
pass
class SearchCompiler(object):
def __init__(self, app):
self.lexer = tokenizer.SearchTokenizer()
self.parser = parser.SearchParser()
def parse(self, data):
return self.parser.parse(data, lexer=self.lexer)

236
gertty/search/parser.py Normal file
View File

@ -0,0 +1,236 @@
# Copyright 2014 Hewlett-Packard Development Company, L.P.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
import datetime
import re
import ply.yacc as yacc
from sqlalchemy.sql.expression import and_, or_
import gertty.db
import gertty.search
from tokenizer import tokens
def SearchParser():
def p_terms(p):
'''expression : list_expr
| paren_expr
| boolean_expr
| negative_expr
| term'''
p[0] = p[1]
def p_list_expr(p):
'''list_expr : expression expression'''
p[0] = and_(p[1], p[2])
def p_paren_expr(p):
'''paren_expr : LPAREN expression RPAREN'''
p[0] = p[2]
def p_boolean_expr(p):
'''boolean_expr : expression AND expression
| expression OR expression'''
if p[2] == 'and':
p[0] = and_(p[1], p[3])
elif p[2] == 'or':
p[0] = or_(p[1], p[3])
else:
raise SyntaxErro()
def p_negative_expr(p):
'''negative_expr : NOT expression
| NEG expression'''
p[0] = not p[1]
def p_term(p):
'''term : age_term
| change_term
| owner_term
| reviewer_term
| commit_term
| project_term
| project_key_term
| branch_term
| topic_term
| ref_term
| label_term
| message_term
| comment_term
| has_term
| is_term
| status_term
| op_term'''
p[0] = p[1]
def p_string(p):
'''string : SSTRING
| DSTRING
| USTRING'''
p[0] = p[1]
def p_age_unit(p):
'''age_unit : SECONDS
| MINUTES
| HOURS
| DAYS
| WEEKS
| MONTHS
| YEARS'''
p[0] = p[1]
def p_age_term(p):
'''age_term : OP_AGE NUMBER age_unit'''
now = datetime.datetime.utcnow()
delta = p[1]
unit = p[2]
if unit == 'minutes':
delta = delta * 60
elif unit == 'hours':
delta = delta * 60 * 60
elif unit == 'days':
delta = delta * 60 * 60 * 60
elif unit == 'weeks':
delta = delta * 60 * 60 * 60 * 7
elif unit == 'months':
delta = delta * 60 * 60 * 60 * 30
elif unit == 'years':
delta = delta * 60 * 60 * 60 * 365
p[0] = gertty.db.change_table.c.updated < (now-delta)
def p_change_term(p):
'''change_term : OP_CHANGE CHANGE_ID
| OP_CHANGE NUMBER'''
if type(p[2]) == int:
p[0] = gertty.db.change_table.c.number == p[2]
else:
p[0] = gertty.db.change_table.c.change_id == p[2]
def p_owner_term(p):
'''owner_term : OP_OWNER string'''
p[0] = gertty.db.change_table.c.owner == p[2]
def p_reviewer_term(p):
'''reviewer_term : OP_REVIEWER string'''
p[0] = gertty.db.approval_table.c.name == p[2]
def p_commit_term(p):
'''commit_term : OP_COMMIT string'''
p[0] = gertty.db.revision_table.c.commit == p[2]
def p_project_term(p):
'''project_term : OP_PROJECT string'''
#TODO: support regex
p[0] = gertty.db.project_table.c.name == p[2]
def p_project_key_term(p):
'''project_key_term : OP_PROJECT_KEY NUMBER'''
p[0] = gertty.db.change_table.c.project_key == p[2]
def p_branch_term(p):
'''branch_term : OP_BRANCH string'''
#TODO: support regex
p[0] = gertty.db.change_table.c.branch == p[2]
def p_topic_term(p):
'''topic_term : OP_TOPIC string'''
#TODO: support regex
p[0] = gertty.db.change_table.c.topic == p[2]
def p_ref_term(p):
'''ref_term : OP_REF string'''
#TODO: support regex
p[0] = gertty.db.change_table.c.branch == p[2][len('refs/heads/'):]
label_re = re.compile(r'(?P<label>[a-zA-Z0-9_-]+([a-zA-Z]|((?<![-+])[0-9])))'
r'(?P<operator>[<>]?=?)(?P<value>[-+]?[0-9]+)'
r'($|,user=(?P<user>\S+))')
def p_label_term(p):
'''label_term : OP_LABEL string'''
args = label_re.match(p[2])
label = args.group('label')
op = args.group('operator') or '='
value = int(args.group('value'))
user = args.group('user')
filters = []
filters.append(gertty.db.approval_table.c.category == label)
if op == '=':
filters.append(gertty.db.approval_table.c.value == value)
elif op == '>=':
filters.append(gertty.db.approval_table.c.value >= value)
elif op == '<=':
filters.append(gertty.db.approval_table.c.value <= value)
if user is not None:
filters.append(gertty.db.approval_table.c.name == user)
p[0] = and_(*filters)
def p_message_term(p):
'''message_term : OP_MESSAGE string'''
p[0] = gertty.db.revision_table.c.message.like(p[1])
def p_comment_term(p):
'''comment_term : OP_COMMENT string'''
p[0] = and_(gertty.db.message_table.c.message.like(p[1]),
gertty.db.comment_table.c.message.like(p[1]))
def p_has_term(p):
'''has_term : OP_HAS string'''
#TODO: implement star
if p[2] == 'draft':
p[0] = gertty.db.message_table.c.pending == True
else:
raise gertty.search.SearchSyntaxError('Syntax error: has:%s is not supported' % p[2])
def p_is_term(p):
'''is_term : OP_IS string'''
#TODO: implement starred, watched, owner, reviewer, draft
if p[2] == 'reviewed':
p[0] = gertty.db.approval_table.c.value != 0
elif p[2] == 'open':
p[0] = gertty.db.change_table.c.status.notin_(['MERGED', 'ABANDONED'])
elif p[2] == 'closed':
p[0] = gertty.db.change_table.c.status.in_(['MERGED', 'ABANDONED'])
elif p[2] == 'submitted':
p[0] = gertty.db.change_table.c.status == 'SUBMITTED'
elif p[2] == 'merged':
p[0] = gertty.db.change_table.c.status == 'MERGED'
elif p[2] == 'abandoned':
p[0] = gertty.db.change_table.c.status == 'ABANDONED'
else:
raise gertty.search.SearchSyntaxError('Syntax error: has:%s is not supported' % p[2])
def p_status_term(p):
'''status_term : OP_STATUS string'''
if p[2] == 'open':
p[0] = gertty.db.change_table.c.status.notin_(['MERGED', 'ABANDONED'])
elif p[2] == 'closed':
p[0] = gertty.db.change_table.c.status.in_(['MERGED', 'ABANDONED'])
else:
p[0] = gertty.db.change_table.c.status == p[2].upper()
def p_op_term(p):
'op_term : OP'
raise SyntaxError()
def p_error(p):
if p:
raise gertty.search.SearchSyntaxError('Syntax error at "%s" in search string "%s" (col %s)' % (
p.lexer.lexdata[p.lexpos:], p.lexer.lexdata, p.lexpos))
else:
raise gertty.search.SearchSyntaxError('Syntax error: EOF in search string')
return yacc.yacc(debug=0, write_tables=0)

73
gertty/search/test.py Normal file
View File

@ -0,0 +1,73 @@
import gertty.search
import re
import sys
label_re = re.compile(r'(?P<label>[a-zA-Z0-9_-]+([a-zA-Z]|((?<![-+])[0-9])))'
r'(?P<operator>[<>]?=?)(?P<value>[-+]?[0-9]+)'
r'($|,user=(?P<user>\S+))')
for a in [
'Code-Review=1',
'Code-Review=+1',
'Code-Review=-1',
'Code-Review>=+1',
'Code-Review<=-1',
'Code-Review+1',
'Code-Review-1',
]:
for b in [
'',
',user=corvus',
]:
data = a+b
print
print data
m = label_re.match(data)
print 'res', m and m.groups()
#sys.exit(0)
parser = gertty.search.SearchCompiler(None)
import tokenizer
lexer = tokenizer.SearchTokenizer()
lexer.input("project:foo/bar")
# Tokenize
while True:
tok = lexer.token()
if not tok: break # No more input
print tok
#TODO: unit test
for a in [
'label:Code-Review=1',
'label:Code-Review=+1',
'label:Code-Review=-1',
'label:Code-Review>=+1',
'label:Code-Review<=-1',
'label:Code-Review+1',
'label:Code-Review-1',
]:
for b in [
'',
',user=corvus',
]:
data = a+b
print
print data
result = parser.parse(data)
print 'res', str(result)
for data in [
'_project_key:18 status:open',
'project:foo/bar status:open',
'project:foo and status:open',
'project:foo or status:open',
'project:foo and (status:merged or status:new)',
'project:foo or project:bar or project:baz',
'project:foo project:bar project:baz',
]:
print
print data
result = parser.parse(data)
print 'res', str(result)

156
gertty/search/tokenizer.py Normal file
View File

@ -0,0 +1,156 @@
# Copyright 2014 Hewlett-Packard Development Company, L.P.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
import ply.lex as lex
operators = {
'age': 'OP_AGE',
'change': 'OP_CHANGE',
'owner': 'OP_OWNER',
#'OP_OWNERIN', # needs local group membership
'reviewer': 'OP_REVIEWER',
#'OP_REVIEWERIN', # needs local group membership
'commit': 'OP_COMMIT',
'project': 'OP_PROJECT',
'_project_key': 'OP_PROJECT_KEY', # internal gertty use only
'branch': 'OP_BRANCH',
'topic': 'OP_TOPIC',
'ref': 'OP_REF',
#'tr': 'OP_TR', # needs trackingids
#'bug': 'OP_BUG', # needs trackingids
'label': 'OP_LABEL',
'message': 'OP_MESSAGE',
'comment': 'OP_COMMENT',
#'file': 'OP_FILE', # needs local file list
'has': 'OP_HAS',
'is': 'OP_IS',
'status': 'OP_STATUS',
}
reserved = {
'or|OR': 'OR',
'not|NOT': 'NOT',
}
tokens = [
'OP',
'AND',
'OR',
'NOT',
'NEG',
'LPAREN',
'RPAREN',
'SECONDS',
'MINUTES',
'HOURS',
'DAYS',
'WEEKS',
'MONTHS',
'YEARS',
'NUMBER',
'CHANGE_ID',
'SSTRING',
'DSTRING',
'USTRING',
#'REGEX',
#'SHA',
] + operators.values()
def SearchTokenizer():
t_CHANGE_ID = r'I[a-fA-F0-9]{7,40}'
t_LPAREN = r'\('
t_RPAREN = r'\)'
t_NEG = r'!'
def t_OP(t):
r'[a-zA-Z_][a-zA-Z_]*:'
t.type = operators.get(t.value[:-1], 'OP')
return t
def t_SSTRING(t):
r"'([^\\']+|\\'|\\\\)*'"
t.value=t.value[1:-1].decode("string-escape")
return t
def t_DSTRING(t):
r'"([^\\"]+|\\"|\\\\)*"'
t.value=t.value[1:-1].decode("string-escape")
return t
def t_AND(t):
r'and|AND'
return t
def t_OR(t):
r'or|OR'
return t
def t_NOT(t):
r'not|NOT'
return t
def t_INTEGER(t):
r'[+-]\d+'
t.value = int(t.value)
return t
def t_NUMBER(t):
r'\d+'
t.value = int(t.value)
return t
def t_USTRING(t):
r'([^\s\(\)!]+)'
t.value=t.value.decode("string-escape")
return t
def t_SECONDS(t):
r's|sec|second|seconds'
t.value = 'seconds'
def t_MINUTES(t):
r'm|min|minute|minutes'
t.value = 'minutes'
def t_HOURS(t):
r'h|hr|hour|hours'
t.value = 'hours'
def t_DAYS(t):
r'd|day|days'
t.value = 'days'
def t_WEEKS(t):
r'w|week|weeks'
t.value = 'weeks'
def t_MONTHS(t):
r'mon|month|months'
t.value = 'months'
def t_YEARS(t):
r'y|year|years'
t.value = 'years'
def t_newline(t):
r'\n+'
t.lexer.lineno += len(t.value)
t_ignore = ' \t'
def t_error(t):
print "Illegal character '%s'" % t.value[0]
t.lexer.skip(1)
return lex.lex()

View File

@ -129,7 +129,7 @@ class ProjectListView(urwid.WidgetWrap):
project_key, project_name = data project_key, project_name = data
self.app.changeScreen(view_change_list.ChangeListView( self.app.changeScreen(view_change_list.ChangeListView(
self.app, self.app,
"project_key:%s status:open" % project_key, "_project_key:%s status:open" % project_key,
project_name, unreviewed=True)) project_name, unreviewed=True))
def keypress(self, size, key): def keypress(self, size, key):

View File

@ -9,3 +9,4 @@ ordereddict
alembic>=0.4.1 alembic>=0.4.1
PyYAML>=3.1.0 PyYAML>=3.1.0
voluptuous>=0.7 voluptuous>=0.7
ply>=3.4