diff --git a/gertty/alembic/versions/3610c2543e07_add_conflicts_table.py b/gertty/alembic/versions/3610c2543e07_add_conflicts_table.py new file mode 100644 index 0000000..d0fb6c4 --- /dev/null +++ b/gertty/alembic/versions/3610c2543e07_add_conflicts_table.py @@ -0,0 +1,27 @@ +"""add conflicts table + +Revision ID: 3610c2543e07 +Revises: 4388de50824a +Create Date: 2016-02-05 16:43:20.047238 + +""" + +# revision identifiers, used by Alembic. +revision = '3610c2543e07' +down_revision = '4388de50824a' + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + op.create_table('change_conflict', + sa.Column('key', sa.Integer(), nullable=False), + sa.Column('change1_key', sa.Integer(), sa.ForeignKey('change.key'), index=True), + sa.Column('change2_key', sa.Integer(), sa.ForeignKey('change.key'), index=True), + sa.PrimaryKeyConstraint('key') + ) + + +def downgrade(): + pass diff --git a/gertty/db.py b/gertty/db.py index 4a92e31..c16454a 100644 --- a/gertty/db.py +++ b/gertty/db.py @@ -82,6 +82,12 @@ change_table = Table( Column('pending_status', Boolean, index=True, nullable=False), Column('pending_status_message', Text), ) +change_conflict_table = Table( + 'change_conflict', metadata, + Column('key', Integer, primary_key=True), + Column('change1_key', Integer, ForeignKey("change.key"), index=True), + Column('change2_key', Integer, ForeignKey("change.key"), index=True), + ) revision_table = Table( 'revision', metadata, Column('key', Integer, primary_key=True), @@ -366,6 +372,30 @@ class Change(object): owner_name = self.owner.email return owner_name + @property + def conflicts(self): + return tuple(set(self.conflicts1 + self.conflicts2)) + + def addConflict(self, other): + session = Session.object_session(self) + if other in self.conflicts1 or other in self.conflicts2: + return + if self in other.conflicts1 or self in other.conflicts2: + return + self.conflicts1.append(other) + session.flush() + session.expire(other, attribute_names=['conflicts2']) + + def delConflict(self, other): + session = Session.object_session(self) + if other in self.conflicts1: + self.conflicts1.remove(other) + session.flush() + session.expire(other, attribute_names=['conflicts2']) + if self in other.conflicts1: + other.conflicts1.remove(self) + session.flush() + session.expire(self, attribute_names=['conflicts2']) class Revision(object): def __init__(self, change, number, message, commit, parent, @@ -586,6 +616,16 @@ mapper(Topic, topic_table, properties=dict( mapper(ProjectTopic, project_topic_table) mapper(Change, change_table, properties=dict( owner=relationship(Account), + conflicts1=relationship(Change, + secondary=change_conflict_table, + primaryjoin=change_table.c.key==change_conflict_table.c.change1_key, + secondaryjoin=change_table.c.key==change_conflict_table.c.change2_key, + ), + conflicts2=relationship(Change, + secondary=change_conflict_table, + primaryjoin=change_table.c.key==change_conflict_table.c.change2_key, + secondaryjoin=change_table.c.key==change_conflict_table.c.change1_key, + ), revisions=relationship(Revision, backref='change', order_by=revision_table.c.number, cascade='all, delete-orphan'), diff --git a/gertty/sync.py b/gertty/sync.py index 8e659d3..8415eed 100644 --- a/gertty/sync.py +++ b/gertty/sync.py @@ -351,29 +351,7 @@ class SyncProjectTask(Task): else: query += ' status:open' queries.append(query) - changes = [] - sortkey = '' - done = False - offset = 0 - while not done: - query = '&'.join(queries) - # We don't actually want to limit to 500, but that's the server-side default, and - # if we don't specify this, we won't get a _more_changes flag. - q = 'changes/?n=500%s&%s' % (sortkey, query) - self.log.debug('Query: %s ' % (q,)) - responses = sync.get(q) - if len(queries) == 1: - responses = [responses] - done = True - for batch in responses: - changes += batch - if batch and '_more_changes' in batch[-1]: - done = False - if '_sortkey' in batch[-1]: - sortkey = '&N=%s' % (batch[-1]['_sortkey'],) - else: - offset += len(batch) - sortkey = '&start=%s' % (offset,) + changes = sync.query(queries) change_ids = [c['id'] for c in changes] with app.db.getSession() as session: # Winnow the list of IDs to only the ones in the local DB. @@ -566,6 +544,8 @@ class SyncChangeTask(Task): for remote_commit, remote_revision in remote_change.get('revisions', {}).items(): remote_comments_data = sync.get('changes/%s/revisions/%s/comments' % (self.change_id, remote_commit)) remote_revision['_gertty_remote_comments_data'] = remote_comments_data + remote_conflicts = sync.query(['q=status:open+is:mergeable+conflicts:%s' % + remote_change['_number']]) fetches = collections.defaultdict(list) parent_commits = set() with app.db.getSession() as session: @@ -600,6 +580,28 @@ class SyncChangeTask(Task): change.subject = remote_change['subject'] change.updated = dateutil.parser.parse(remote_change['updated']) change.topic = remote_change.get('topic') + unseen_conflicts = [x.id for x in change.conflicts] + for remote_conflict in remote_conflicts: + conflict_id = remote_conflict['id'] + conflict = session.getChangeByID(conflict_id) + if not conflict: + self.log.info("Need to sync conflicting change %s for change %s.", + conflict_id, change.number) + sync.submitTask(SyncChangeTask(conflict_id, priority=self.priority)) + else: + if conflict not in change.conflicts: + self.log.info("Added conflict %s for change %s in local DB.", + conflict.number, change.number) + change.addConflict(conflict) + self.results.append(ChangeUpdatedEvent(conflict)) + if conflict_id in unseen_conflicts: + unseen_conflicts.remove(conflict_id) + for conflict_id in unseen_conflicts: + conflict = session.getChangeByID(conflict_id) + self.log.info("Deleted conflict %s for change %s in local DB.", + conflict.number, change.number) + change.delConflict(conflict) + self.results.append(ChangeUpdatedEvent(conflict)) repo = gitrepo.get_repo(change.project.name, app.config) new_revision = False for remote_commit, remote_revision in remote_change.get('revisions', {}).items(): @@ -1290,6 +1292,8 @@ class VacuumDatabaseTask(Task): session.vacuum() class Sync(object): + _quiet_debug_mode = False + def __init__(self, app): self.user_agent = 'Gertty/%s %s' % (gertty.version.version_info.release_string(), requests.utils.default_user_agent()) @@ -1308,16 +1312,17 @@ class Sync(object): self.auth = authclass( self.app.config.username, self.app.config.password) self.submitTask(GetVersionTask(HIGH_PRIORITY)) - self.submitTask(SyncOwnAccountTask(HIGH_PRIORITY)) - self.submitTask(CheckReposTask(HIGH_PRIORITY)) - self.submitTask(UploadReviewsTask(HIGH_PRIORITY)) - self.submitTask(SyncProjectListTask(HIGH_PRIORITY)) - self.submitTask(SyncSubscribedProjectsTask(NORMAL_PRIORITY)) - self.submitTask(SyncSubscribedProjectBranchesTask(LOW_PRIORITY)) - self.submitTask(PruneDatabaseTask(self.app.config.expire_age, LOW_PRIORITY)) - self.periodic_thread = threading.Thread(target=self.periodicSync) - self.periodic_thread.daemon = True - self.periodic_thread.start() + if not self._quiet_debug_mode: + self.submitTask(SyncOwnAccountTask(HIGH_PRIORITY)) + self.submitTask(CheckReposTask(HIGH_PRIORITY)) + self.submitTask(UploadReviewsTask(HIGH_PRIORITY)) + self.submitTask(SyncProjectListTask(HIGH_PRIORITY)) + self.submitTask(SyncSubscribedProjectsTask(NORMAL_PRIORITY)) + self.submitTask(SyncSubscribedProjectBranchesTask(LOW_PRIORITY)) + self.submitTask(PruneDatabaseTask(self.app.config.expire_age, LOW_PRIORITY)) + self.periodic_thread = threading.Thread(target=self.periodicSync) + self.periodic_thread.daemon = True + self.periodic_thread.start() def periodicSync(self): hourly = time.time() @@ -1475,3 +1480,29 @@ class Sync(object): micro = int(parts[2]) self.version = (major, minor, micro) self.log.info("Remote version is: %s (parsed as %s)" % (version, self.version)) + + def query(self, queries): + changes = [] + sortkey = '' + done = False + offset = 0 + while not done: + query = '&'.join(queries) + # We don't actually want to limit to 500, but that's the server-side default, and + # if we don't specify this, we won't get a _more_changes flag. + q = 'changes/?n=500%s&%s' % (sortkey, query) + self.log.debug('Query: %s' % (q,)) + responses = self.get(q) + if len(queries) == 1: + responses = [responses] + done = True + for batch in responses: + changes += batch + if batch and '_more_changes' in batch[-1]: + done = False + if '_sortkey' in batch[-1]: + sortkey = '&N=%s' % (batch[-1]['_sortkey'],) + else: + offset += len(batch) + sortkey = '&start=%s' % (offset,) + return changes diff --git a/gertty/view/change.py b/gertty/view/change.py index 03ef647..b9212af 100644 --- a/gertty/view/change.py +++ b/gertty/view/change.py @@ -485,7 +485,9 @@ class ChangeView(urwid.WidgetWrap): self.depends_on_rows = {} self.needed_by = urwid.Pile([]) self.needed_by_rows = {} - self.related_changes = urwid.Pile([self.depends_on, self.needed_by]) + self.conflicts_with = urwid.Pile([]) + self.conflicts_with_rows = {} + self.related_changes = urwid.Pile([self.depends_on, self.needed_by, self.conflicts_with]) self.results = mywid.HyperText(u'') # because it scrolls better than a table self.grid = mywid.MyGridFlow([change_info, self.commit_message, votes, self.results], cell_width=80, h_sep=2, v_sep=1, align='left') @@ -770,6 +772,16 @@ class ChangeView(urwid.WidgetWrap): self.needed_by, self.needed_by_rows, header='Needed by:') + # Handle conflicts_with + conflicts = {} + conflicts.update((c.key, c.subject) + for c in change.conflicts + if (c.status != 'MERGED' and + c.status != 'ABANDONED')) + self._updateDependenciesWidget(conflicts, + self.conflicts_with, self.conflicts_with_rows, + header='Conflicts with:') + def toggleReviewed(self): with self.app.db.getSession() as session: