This instance will be upgraded to Heptapod 0.21.0rc2 on 2021-04-16 at 11:00 UTC+2 (a few minutes of down time)

phabreview.py 20 KB
Newer Older
1 2 3 4 5 6 7 8 9
# phabreview.py - TortoiseHg's dialog for posting patches to Phabricator
#
# Copyright 2018 Matt Harbison <mharbison72@gmail.com>
#
# This software may be used and distributed according to the terms of the
# GNU General Public License version 2, incorporated herein by reference.

from __future__ import absolute_import

10 11 12 13
from mercurial import (
    pycompat,
)

14
from .qtcore import (
15 16
    QBuffer,
    QIODevice,
17
    QSettings,
18
    QSortFilterProxyModel,
19 20 21 22 23 24
    Qt,
    pyqtSlot,
)
from .qtgui import (
    QDialog,
    QKeySequence,
25
    QListWidgetItem,
26
    QShortcut,
27 28
    QStandardItem,
    QStandardItemModel,
29 30 31 32 33
)

from ..util import hglib
from ..util.i18n import _
from . import (
Matt Harbison's avatar
Matt Harbison committed
34
    cmdcore,
35
    cmdui,
Matt Harbison's avatar
Matt Harbison committed
36
    lexers,
37 38 39 40 41 42
    qtlib,
)

from .hgemail import _ChangesetsModel
from .phabreview_ui import Ui_PhabReviewDialog

43 44 45 46 47
class user(tuple):
    '''Named tuple with user properties.'''

    __slots__ = ()

48 49
    def __new__(cls, username, realname, roles):
        return tuple.__new__(cls, (username, realname, roles))
50 51 52 53 54 55 56 57 58 59 60

    @property
    def username(self):
        '''username used by phabsend'''
        return self[0]

    @property
    def realname(self):
        '''friendly name of this user'''
        return self[1]

61 62 63 64 65
    @property
    def roles(self):
        '''the roles filled by this user'''
        return self[2]

66 67 68
    def __repr__(self):
        return '%s (%s)' % (self.realname, self.username)

69

70 71 72 73 74 75 76 77 78 79 80
class PhabReviewDialog(QDialog):
    """Dialog for posting patches to Phabricator"""

    def __init__(self, repoagent, revs, parent=None):
        """Create PhabReviewDialog for the given repo and revs

        :revs: List of revisions to be sent.
        """
        super(PhabReviewDialog, self).__init__(parent)
        self.setWindowFlags(Qt.Window)
        self._repoagent = repoagent
Matt Harbison's avatar
Matt Harbison committed
81
        self._cmdsession = cmdcore.nullCmdSession()
82
        self._rescansession = cmdcore.nullCmdSession()
83 84
        self._namesession = cmdcore.nullCmdSession()
        self._my_username = None
85 86 87 88

        self._qui = Ui_PhabReviewDialog()
        self._qui.setupUi(self)

89 90 91 92
        proxymodel = QSortFilterProxyModel(self._qui.available_reviewer_list)
        proxymodel.setDynamicSortFilter(True)
        proxymodel.setSourceModel(QStandardItemModel())
        proxymodel.setFilterCaseSensitivity(Qt.CaseInsensitive)
93
        proxymodel.sort(0)
94 95 96 97 98 99 100 101 102 103 104 105 106 107
        self.availablereviewersmodel = proxymodel

        reviewerlist = self._qui.available_reviewer_list
        reviewerlist.setModel(proxymodel)
        reviewerlist.selectionModel().selectionChanged.connect(
            self.on_available_reviewer_selection_changed)

        reviewerfilter = self._qui.reviewer_filter
        reviewerfilter.textChanged.connect(proxymodel.setFilterFixedString)

        selectedreviewerlist = self._qui.selected_reviewers_list
        selectedreviewerlist.selectionModel().selectionChanged.connect(
            self.on_reviewer_selection_changed)

108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123
        callsign = self._ui.config(b'phabricator', b'callsign')
        url = self._ui.config(b'phabricator', b'url')

        title = self.windowTitle()
        if url:
            title += hglib.tounicode(b" - " + url)
            if callsign:
                # The callsign is repo specific, so pointing to the repo in
                # Diffusion seems like a reasonable way to display both pieces
                # of Phabricator config info.
                endfmt = url.endswith(b'/') and b'r%s' or b'/r%s'
                title += hglib.tounicode(endfmt % callsign)

            self.setWindowTitle(title)

        self._initchangesets(revs)
Matt Harbison's avatar
Matt Harbison committed
124
        self._initpreviewtab()
125
        self._readreviewerhistory()
126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142
        self._updateforms()
        self._readsettings()
        QShortcut(QKeySequence('Ctrl+Return'), self, self.accept)
        QShortcut(QKeySequence('Ctrl+Enter'), self, self.accept)

    def closeEvent(self, event):
        self._writesettings()
        super(PhabReviewDialog, self).closeEvent(event)

    def _readsettings(self):
        s = QSettings()
        self.restoreGeometry(qtlib.readByteArray(s, 'phabsend/geom'))

    def _writesettings(self):
        s = QSettings()
        s.setValue('phabsend/geom', self.saveGeometry())

143
    def _reviewerhistorypath(self, withcallsign=False):
144 145 146 147 148 149 150 151 152 153
        '''Fetches the path in the settings used to store reviewer history.

        If no reviewers are stored or the configuration isn't present to find
        the reviewers, no path is returned.
        '''
        url = self._ui.config(b'phabricator', b'url')
        if not url:
            return None

        scheme, hostpath = url.split(b'://', 1)
154
        if hostpath.endswith(b'/'):
155 156
            hostpath = hostpath[0:-1]

157 158 159 160 161 162 163 164 165
        if not withcallsign:
            return 'phabsend/%s/reviewers' % hostpath

        callsign = self._ui.config(b'phabricator', b'callsign')

        if not callsign:
            return None

        return 'phabsend/%s/%s/reviewers' % (hostpath, callsign)
166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183

    def _getreviewerhistory(self):
        '''Returns a fully populated list of the users previously selected to
        perform a review for any repository on the configured server.
        '''
        path = self._reviewerhistorypath()
        if not path:
            return []

        s = QSettings()

        reviewers = []

        size = s.beginReadArray(path)
        for idx in pycompat.xrange(size):
            s.setArrayIndex(idx)
            realname = qtlib.readString(s, "realname")
            username = qtlib.readString(s, "username")
184 185
            roles = qtlib.readStringList(s, "roles")
            reviewers.append(user(username, realname, roles))
186 187 188 189 190
        s.endArray()

        return reviewers

    def _readreviewerhistory(self):
191 192 193 194 195 196
        '''Pre-populates the reviewer lists.

        The available reviewer list is populated with all reviewers selected
        in the past from the configured server.  The selected reviewers are
        populated with the reviewers selected for the previous review on the
        repository, as identified by the callsign.
197 198 199
        '''
        availablereviewers = self.availablereviewersmodel.sourceModel()

200 201
        history = {r.username: r for r in self._getreviewerhistory()}
        for reviewer in history.values():
202
            item = QStandardItem(pycompat.unicode(reviewer))
203 204 205 206 207
            item.setData(reviewer)

            # Must add to source model since setDynamicSortFilter() is True.
            availablereviewers.appendRow(item)

208 209 210 211 212 213 214 215 216 217 218 219
        # Preselect the last set of reviewers for this repository, if known.
        path = self._reviewerhistorypath(withcallsign=True)
        if path:
            s = QSettings()
            reviewers = self._qui.selected_reviewers_list
            size = s.beginReadArray(path)

            for idx in pycompat.xrange(size):
                s.setArrayIndex(idx)
                username = qtlib.readString(s, "username")
                reviewer = history.get(username)
                if reviewer:
220
                    witem = QListWidgetItem(pycompat.unicode(reviewer))
221 222 223 224
                    witem.setData(Qt.UserRole + 1, reviewer)
                    reviewers.addItem(witem)
            s.endArray()

225
    def _writereviewerhistory(self):
226
        '''Stores the selected reviewers persistently.
227

228 229 230 231 232
        The currently selected reviewers are combined with the existing reviewer
        history, and it is stored.  This allows the available reviewer list to
        be pre-populated with users of interest, without contacting the server.
        This cumulative history is shared across repositories that use the same
        phabricator server.
233 234 235 236 237
        '''
        path = self._reviewerhistorypath()
        if not path:
            return []

238 239
        # Avoid set() when eliminating duplicates, which hashes the role list
        reviewers = {r.username: r for r in self._getreviewerhistory()}
240 241 242 243
        selectedreviewers = self._qui.selected_reviewers_list

        for i in pycompat.xrange(selectedreviewers.count()):
            reviewer = selectedreviewers.item(i).data(Qt.UserRole + 1)
244
            reviewers[reviewer.username] = reviewer
245 246 247 248

        s = QSettings()
        s.beginWriteArray(path)

249
        for i, reviewer in enumerate(reviewers.values()):
250 251 252
            s.setArrayIndex(i)
            s.setValue("username", reviewer.username)
            s.setValue("realname", reviewer.realname)
253
            s.setValue("roles", reviewer.roles)
254 255 256

        s.endArray()

257 258 259 260 261 262 263 264 265 266 267 268
        # Store the currently selected reviewers, if tied to a repo.
        path = self._reviewerhistorypath(withcallsign=True)
        if path:
            reviewers = self._qui.selected_reviewers_list
            s.beginWriteArray(path)

            for i in pycompat.xrange(reviewers.count()):
                s.setArrayIndex(i)
                s.setValue("username",
                           reviewers.item(i).data(Qt.UserRole + 1).username)
            s.endArray()

269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297
    def _initchangesets(self, revs):
        # Posting a review amends the commits, making them hidden.  That in
        # turn would raise a FilteredRepoLookupError each time the table tries
        # to repaint after posting the review.  And that makes it almost
        # impossible to close this dialog.
        self._changesets = _ChangesetsModel(self._repo.unfiltered(),
                                            revs=revs or list(self._repo),
                                            selectedrevs=revs,
                                            parent=self)
        self._changesets.dataChanged.connect(self._updateforms)
        self._qui.changesets_view.setModel(self._changesets)

    @property
    def _repo(self):
        return self._repoagent.rawRepo()

    @property
    def _ui(self):
        return self._repo.ui

    @property
    def _revs(self):
        """Returns list of revisions to be sent"""
        return self._changesets.selectedrevs

    def _phabsendopts(self, **opts):
        """Generate opts for phabsend by form values"""
        opts['rev'] = hglib.compactrevs(self._revs)

298
        reviewerlist = self._qui.selected_reviewers_list
299
        opts['reviewer'] = [reviewerlist.item(i).data(Qt.UserRole + 1).username
300
                            for i in pycompat.xrange(reviewerlist.count())]
301

302 303 304 305 306 307 308 309 310 311 312 313 314 315
        return opts

    def _isvalid(self):
        """Filled all required values?"""
        if not self._revs:
            return False

        return True

    @pyqtSlot()
    def _updateforms(self):
        """Update availability of form widgets"""
        valid = self._isvalid()
        self._qui.send_button.setEnabled(valid)
Matt Harbison's avatar
Matt Harbison committed
316
        self._qui.main_tabs.setTabEnabled(self._previewtabindex(), valid)
317 318 319 320 321 322 323 324

    def accept(self):
        opts = self._phabsendopts()
        cmdline = hglib.buildcmdargs('phabsend', **opts)
        cmd = cmdui.CmdSessionDialog(self)
        cmd.setWindowTitle(_('Posting Review'))
        cmd.setLogVisible(True)
        cmd.setSession(self._repoagent.runCommand(cmdline, self))
325 326
        if cmd.exec_() == 0:
            self._writereviewerhistory()
327

Matt Harbison's avatar
Matt Harbison committed
328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365
    def _initpreviewtab(self):
        def initqsci(w):
            w.setUtf8(True)
            w.setReadOnly(True)
            w.setMarginWidth(1, 0)  # hide area for line numbers
            self.lexer = lex = lexers.difflexer(self)
            fh = qtlib.getfont('fontdiff')
            fh.changed.connect(self.forwardFont)
            lex.setFont(fh.font())
            w.setLexer(lex)
            # TODO: better way to setup diff lexer

        initqsci(self._qui.preview_edit)

        self._qui.main_tabs.currentChanged.connect(self._refreshpreviewtab)
        self._refreshpreviewtab(self._qui.main_tabs.currentIndex())

    def forwardFont(self, font):
        if self.lexer:
            self.lexer.setFont(font)

    @pyqtSlot(int)
    def _refreshpreviewtab(self, index):
        """Generate preview text if current tab is preview"""
        if self._previewtabindex() != index:
            return

        self._qui.preview_edit.clear()

        cmdline = hglib.buildcmdargs('export', git=True,
                                     rev=hglib.compactrevs(self._revs))
        self._cmdsession = sess = self._repoagent.runCommand(cmdline)
        sess.setCaptureOutput(True)
        sess.commandFinished.connect(self._updatepreview)

    @pyqtSlot()
    def _updatepreview(self):
        preview = self._qui.preview_edit
366
        exported = hglib.tounicode(bytes(self._cmdsession.readAll()))
Matt Harbison's avatar
Matt Harbison committed
367 368 369 370 371 372 373 374

        callsign = self._ui.config(b'phabricator', b'callsign')
        if not callsign:
            callsign = b'Not Configured!'
        url = self._ui.config(b'phabricator', b'url')
        if not url:
            url = b'Not Configured!'

375
        reviewerlist = self._qui.selected_reviewers_list
376 377
        role = Qt.UserRole + 1

378
        reviewers = [reviewerlist.item(i).data(role).username
379
                     for i in pycompat.xrange(reviewerlist.count())]
380 381 382 383 384
        if reviewers:
            reviewers = u', '.join(reviewers)
        else:
            reviewers = u'None'

Matt Harbison's avatar
Matt Harbison committed
385 386
        preview.append(u'Server:    %s\n' % hglib.tounicode(url))
        preview.append(u'Callsign:  %s\n' % hglib.tounicode(callsign))
387
        preview.append(u'Reviewers: %s\n' % reviewers)
Matt Harbison's avatar
Matt Harbison committed
388 389 390 391 392 393 394 395
        preview.append(u'\n\n')

        preview.append(exported)

    def _previewtabindex(self):
        """Index of preview tab"""
        return self._qui.main_tabs.indexOf(self._qui.preview_tab)

396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428
    def _appendavailablereviewers(self, conduitresult):
        """Process the result of a conduit call, and add reviewers to the
        available reviewers list.
        """
        availablereviewers = self.availablereviewersmodel.sourceModel()

        # A single user JSON entry looks like this:
        #
        #   {
        #     "attachments": {},
        #     "fields": {
        #       "dateCreated": 1544870724,
        #       "dateModified": 1544870725,
        #       "policy": {
        #         "edit": "no-one",
        #         "view": "public"
        #       },
        #       "realName": "UserName LastName",
        #       "roles": [
        #         "admin",
        #         "verified",
        #         "approved",
        #         "activated"   <-- or "disabled" for deactivated user
        #       ],
        #       "username": "user"
        #     },
        #     "id": 1,
        #     "phid": "PHID-USER-5f2bb6z25cceqs266inw",
        #     "type": "USER"
        #   }
        #
        # Additional roles include "list" for mailing list entries.

429 430
        for data in conduitresult.get('data', {}):
            fields = data.get('fields', {})
431 432
            realname = fields.get('realName')
            username = fields.get('username')
433
            roles = fields.get('roles', [])
434

435 436 437
            # An error is raised when a user self assigns a review, so skip the
            # current user.
            if not realname or not username or username == self._my_username:
438 439
                continue

440
            u = user(username, realname, roles)
441
            item = QStandardItem(pycompat.unicode(u))
442
            item.setData(u)
443 444 445 446 447 448 449 450 451 452 453 454

            # Must add to source model since setDynamicSortFilter() is True.
            availablereviewers.appendRow(item)

    def _queryreviewers(self, after):
        """Issues the command to query the users, and arranges to have
        _updateavailablereviewers() called on completion.  ``after`` is the
        cursor value from the result of the last command, or None at the start
        of the sequence.
        """
        buf = QBuffer()

455 456 457
        # phabsend doesn't seem to complain about sending reviews to deactivate
        # users, but filter them out anyway.  It also doesn't seem to make much
        # sense to send a review request to a bot.
458 459 460
        if after is not None:
            buf.setData(b'''{
                "constraints": {
461 462
                    "isBot": false,
                    "isDisabled": false
463 464 465 466 467 468
                },
                "after": %s
            }''' % after)
        else:
            buf.setData(b'''{
                "constraints": {
469 470
                    "isBot": false,
                    "isDisabled": false
471 472 473 474 475
                }
            }''')

        buf.open(QIODevice.ReadOnly)

476
        cmdline = hglib.buildcmdargs('debugcallconduit', 'user.search')
477 478 479 480 481 482 483 484 485 486 487 488
        self._rescansession = sess = self._repoagent.runCommand(cmdline)
        sess.setCaptureOutput(True)
        sess.setInputDevice(buf)
        sess.commandFinished.connect(self._updateavailablereviewers)

    @pyqtSlot()
    def on_rescan_button_clicked(self):
        availablereviewers = self.availablereviewersmodel.sourceModel()

        availablereviewers.clear()
        self._qui.rescan_button.setEnabled(False)

489 490 491 492 493
        # On the first click, fetch the current user's name for the purpose of
        # excluding them from the list.  If the username is known, trust it.
        if self._my_username is None:
            self._get_my_username()

494 495
        self._queryreviewers(None)

496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519
    def _get_my_username(self):
        """Spawns the phabricator command to return the current user's info."""
        buf = QBuffer()

        buf.setData(b'{}')
        buf.open(QIODevice.ReadOnly)

        cmdline = hglib.buildcmdargs("debugcallconduit", "user.whoami")
        self._namesession = sess = self._repoagent.runCommand(cmdline)
        sess.setCaptureOutput(True)
        sess.setInputDevice(buf)
        sess.commandFinished.connect(self._update_self_username)

    @pyqtSlot()
    def _update_self_username(self):
        """Handles the completion of the current user info fetch."""
        exitcode = self._namesession.exitCode()

        if exitcode == 0:
            output = bytes(self._namesession.readAll())
            results = pycompat.json_loads(output)

            self._my_username = results.get("userName")

520 521 522 523 524 525
    @pyqtSlot()
    def _updateavailablereviewers(self):

        exitcode = self._rescansession.exitCode()

        if exitcode == 0:
526
            output = bytes(self._rescansession.readAll())
527

528
            results = pycompat.json_loads(output)
529 530 531 532 533 534 535
            self._appendavailablereviewers(results)

            # The default behavior is to batch 100 users at a time, and requires
            # a second call, with the 'after' value specified in the cursor of
            # the response to pick up the rest.  'after' is None at the end of
            # the sequence.  To test the continuation logic, `"limit": 1` can be
            # added to the request parameters.
536 537 538
            cursor = results.get('cursor')
            if cursor and 'after' in cursor:
                after = cursor.get('after')
539 540 541 542
                if after:
                    self._queryreviewers(hglib.fromunicode(after))
                    return
        else:
543 544
            cmdui.errorMessageBox(self._rescansession, self,
                                  "Phabricator Error")
545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582

        # Done, either by error or completing the sequence.
        self._qui.rescan_button.setEnabled(True)

    @pyqtSlot()
    def on_available_reviewer_selection_changed(self):
        view = self._qui.available_reviewer_list
        self._qui.addreviewer_button.setEnabled(bool(view.selectedIndexes()))

    @pyqtSlot()
    def on_reviewer_selection_changed(self):
        view = self._qui.selected_reviewers_list
        self._qui.removereviewer_button.setEnabled(bool(view.selectedIndexes()))

    @pyqtSlot()
    def on_addreviewer_button_clicked(self):
        """Populates the selected reviewers list when the ">" button is clicked.
        """
        reviewers = self._qui.selected_reviewers_list
        proxymodel = self.availablereviewersmodel
        model = proxymodel.sourceModel()

        for i in self._qui.available_reviewer_list.selectedIndexes():
            item = model.item(proxymodel.mapToSource(i).row())
            if not reviewers.findItems(item.text(), Qt.MatchExactly):
                witem = QListWidgetItem(item.text())
                witem.setData(Qt.UserRole + 1, item.data())
                reviewers.addItem(witem)

    @pyqtSlot()
    def on_removereviewer_button_clicked(self):
        """Removes items from the selected reviewers list when the "<" button is
        clicked.
        """
        reviewers = self._qui.selected_reviewers_list
        for i in reviewers.selectedItems():
            reviewers.takeItem(reviewers.row(i))

583 584 585 586 587 588 589
    @pyqtSlot()
    def on_selectall_button_clicked(self):
        self._changesets.selectAll()

    @pyqtSlot()
    def on_selectnone_button_clicked(self):
        self._changesets.selectNone()