~launchpad-pqm/launchpad/devel

« back to all changes in this revision

Viewing changes to lib/lp/bugs/scripts/bugzilla.py

  • Committer: Launchpad Patch Queue Manager
  • Date: 2011-08-09 10:30:43 UTC
  • mfrom: (13607.3.4 bug-820796)
  • Revision ID: launchpad@pqm.canonical.com-20110809103043-865kaypngiquws1f
[r=henninge][bug=820796] Make publisher scripts use the same lock
        file.

Show diffs side-by-side

added added

removed removed

Lines of Context:
1
 
# Copyright 2009 Canonical Ltd.  This software is licensed under the
2
 
# GNU Affero General Public License version 3 (see the file LICENSE).
3
 
 
4
 
"""Bugzilla to Launchpad import logic"""
5
 
 
6
 
 
7
 
# Bugzilla schema:
8
 
# http://lxr.mozilla.org/mozilla/source/webtools/bugzilla/Bugzilla/DB/Schema.pm
9
 
 
10
 
# XXX: jamesh 2005-10-18
11
 
# Currently unhandled bug info:
12
 
#  * Operating system and platform
13
 
#  * version (not really used in Ubuntu bugzilla though)
14
 
#  * keywords
15
 
#  * private bugs (none of the canonical-only bugs seem sensitive though)
16
 
#  * bug dependencies
17
 
#  * "bug XYZ" references inside comment text (at the moment we just
18
 
#    insert the full URL to the bug afterwards).
19
 
#
20
 
# Not all of these are necessary though
21
 
 
22
 
__metaclass__ = type
23
 
 
24
 
from cStringIO import StringIO
25
 
import datetime
26
 
import logging
27
 
import re
28
 
 
29
 
import pytz
30
 
from storm.store import Store
31
 
from zope.component import getUtility
32
 
 
33
 
from canonical.launchpad.interfaces.emailaddress import IEmailAddressSet
34
 
from canonical.launchpad.interfaces.librarian import ILibraryFileAliasSet
35
 
from canonical.launchpad.webapp import canonical_url
36
 
from lp.app.errors import NotFoundError
37
 
from lp.app.interfaces.launchpad import ILaunchpadCelebrities
38
 
from lp.bugs.interfaces.bug import (
39
 
    CreateBugParams,
40
 
    IBugSet,
41
 
    )
42
 
from lp.bugs.interfaces.bugattachment import (
43
 
    BugAttachmentType,
44
 
    IBugAttachmentSet,
45
 
    )
46
 
from lp.bugs.interfaces.bugtask import (
47
 
    BugTaskImportance,
48
 
    BugTaskStatus,
49
 
    IBugTaskSet,
50
 
    )
51
 
from lp.bugs.interfaces.bugwatch import IBugWatchSet
52
 
from lp.bugs.interfaces.cve import ICveSet
53
 
from lp.registry.interfaces.person import (
54
 
    IPersonSet,
55
 
    PersonCreationRationale,
56
 
    )
57
 
from lp.services.messages.interfaces.message import IMessageSet
58
 
 
59
 
 
60
 
logger = logging.getLogger('lp.bugs.scripts.bugzilla')
61
 
 
62
 
def _add_tz(dt):
63
 
    """Convert a naiive datetime value to a UTC datetime value."""
64
 
    assert dt.tzinfo is None, 'add_tz() only accepts naiive datetime values'
65
 
    return datetime.datetime(dt.year, dt.month, dt.day,
66
 
                             dt.hour, dt.minute, dt.second,
67
 
                             dt.microsecond, tzinfo=pytz.timezone('UTC'))
68
 
 
69
 
class BugzillaBackend:
70
 
    """A wrapper for all the MySQL database access.
71
 
 
72
 
    The main purpose of this is to make it possible to test the rest
73
 
    of the import code without access to a MySQL database.
74
 
    """
75
 
    def __init__(self, conn, charset='UTF-8'):
76
 
        self.conn = conn
77
 
        self.cursor = conn.cursor()
78
 
        self.charset = charset
79
 
 
80
 
    def _decode(self, s):
81
 
        if s is not None:
82
 
            value = s.decode(self.charset, 'replace')
83
 
            # Postgres doesn't like values outside of the basic multilingual
84
 
            # plane (U+0000 - U+FFFF), so replace them (and surrogates) with
85
 
            # U+FFFD (replacement character).
86
 
            # Existance of these characters generally indicate an encoding
87
 
            # problem in the existing Bugzilla data.
88
 
            return re.sub(u'[^\u0000-\ud7ff\ue000-\uffff]', u'\ufffd', value)
89
 
        else:
90
 
            return None
91
 
 
92
 
    def lookupUser(self, user_id):
93
 
        """Look up information about a particular Bugzilla user ID"""
94
 
        self.cursor.execute('SELECT login_name, realname '
95
 
                            '  FROM profiles '
96
 
                            '  WHERE userid = %d' % user_id)
97
 
        if self.cursor.rowcount != 1:
98
 
            raise NotFoundError('could not look up user %d' % user_id)
99
 
        (login_name, realname) = self.cursor.fetchone()
100
 
        realname = self._decode(realname)
101
 
        return (login_name, realname)
102
 
 
103
 
    def getBugInfo(self, bug_id):
104
 
        """Retrieve information about a bug."""
105
 
        self.cursor.execute(
106
 
            'SELECT bug_id, assigned_to, bug_file_loc, bug_severity, '
107
 
            '    bug_status, creation_ts, short_desc, op_sys, priority, '
108
 
            '    products.name, rep_platform, reporter, version, '
109
 
            '    components.name, resolution, target_milestone, qa_contact, '
110
 
            '    status_whiteboard, keywords, alias '
111
 
            '  FROM bugs '
112
 
            '    INNER JOIN products ON bugs.product_id = products.id '
113
 
            '    INNER JOIN components ON bugs.component_id = components.id '
114
 
            '  WHERE bug_id = %d' % bug_id)
115
 
        if self.cursor.rowcount != 1:
116
 
            raise NotFoundError('could not look up bug %d' % bug_id)
117
 
        (bug_id, assigned_to, bug_file_loc, bug_severity, bug_status,
118
 
         creation_ts, short_desc, op_sys, priority, product,
119
 
         rep_platform, reporter, version, component, resolution,
120
 
         target_milestone, qa_contact, status_whiteboard, keywords,
121
 
         alias) = self.cursor.fetchone()
122
 
 
123
 
        bug_file_loc = self._decode(bug_file_loc)
124
 
        creation_ts = _add_tz(creation_ts)
125
 
        product = self._decode(product)
126
 
        version = self._decode(version)
127
 
        component = self._decode(component)
128
 
        status_whiteboard = self._decode(status_whiteboard)
129
 
        keywords = self._decode(keywords)
130
 
        alias = self._decode(alias)
131
 
 
132
 
        return (bug_id, assigned_to, bug_file_loc, bug_severity,
133
 
                bug_status, creation_ts, short_desc, op_sys, priority,
134
 
                product, rep_platform, reporter, version, component,
135
 
                resolution, target_milestone, qa_contact,
136
 
                status_whiteboard, keywords, alias)
137
 
 
138
 
    def getBugCcs(self, bug_id):
139
 
        """Get the IDs of the people CC'd to the bug."""
140
 
        self.cursor.execute('SELECT who FROM cc WHERE bug_id = %d'
141
 
                            % bug_id)
142
 
        return [row[0] for row in self.cursor.fetchall()]
143
 
 
144
 
    def getBugComments(self, bug_id):
145
 
        """Get the comments for the bug."""
146
 
        self.cursor.execute('SELECT who, bug_when, thetext '
147
 
                            '  FROM longdescs '
148
 
                            '  WHERE bug_id = %d '
149
 
                            '  ORDER BY bug_when' % bug_id)
150
 
        # XXX: jamesh 2005-12-07:
151
 
        # Due to a bug in Debzilla, Ubuntu bugzilla bug 248 has > 7800
152
 
        # duplicate comments,consisting of someone's signature.
153
 
        # For the import, just ignore those comments.
154
 
        return [(who, _add_tz(when), self._decode(thetext))
155
 
                 for (who, when, thetext) in self.cursor.fetchall()
156
 
                 if thetext != '\n--=20\n   Jacobo Tarr=EDo     |     '
157
 
                               'http://jacobo.tarrio.org/\n\n\n']
158
 
 
159
 
    def getBugAttachments(self, bug_id):
160
 
        """Get the attachments for the bug."""
161
 
        self.cursor.execute('SELECT attach_id, creation_ts, description, '
162
 
                            '    mimetype, ispatch, filename, thedata, '
163
 
                            '    submitter_id '
164
 
                            '  FROM attachments '
165
 
                            '  WHERE bug_id = %d '
166
 
                            '  ORDER BY attach_id' % bug_id)
167
 
        return [(attach_id, _add_tz(creation_ts),
168
 
                 self._decode(description), mimetype,
169
 
                 ispatch, self._decode(filename), thedata, submitter_id)
170
 
                for (attach_id, creation_ts, description,
171
 
                     mimetype, ispatch, filename, thedata,
172
 
                     submitter_id) in self.cursor.fetchall()]
173
 
 
174
 
    def findBugs(self, product=None, component=None, status=None):
175
 
        """Returns the requested bug IDs as a list"""
176
 
        if product is None:
177
 
            product = []
178
 
        if component is None:
179
 
            component = []
180
 
        if status is None:
181
 
            status = []
182
 
        joins = []
183
 
        conditions = []
184
 
        if product:
185
 
            joins.append(
186
 
                'INNER JOIN products ON bugs.product_id = products.id')
187
 
            conditions.append('products.name IN (%s)' %
188
 
                ', '.join([self.conn.escape(p) for p in product]))
189
 
        if component:
190
 
            joins.append(
191
 
                'INNER JOIN components ON bugs.component_id = components.id')
192
 
            conditions.append('components.name IN (%s)' %
193
 
                ', '.join([self.conn.escape(c) for c in component]))
194
 
        if status:
195
 
            conditions.append('bugs.bug_status IN (%s)' %
196
 
                ', '.join([self.conn.escape(s) for s in status]))
197
 
        if conditions:
198
 
            conditions = 'WHERE %s' % ' AND '.join(conditions)
199
 
        else:
200
 
            conditions = ''
201
 
        self.cursor.execute('SELECT bug_id FROM bugs %s %s ORDER BY bug_id' %
202
 
                            (' '.join(joins), conditions))
203
 
        return [bug_id for (bug_id,) in self.cursor.fetchall()]
204
 
 
205
 
    def getDuplicates(self):
206
 
        """Returns a list of (dupe_of, dupe) relations."""
207
 
        self.cursor.execute('SELECT dupe_of, dupe FROM duplicates '
208
 
                            'ORDER BY dupe, dupe_of')
209
 
        return [(dupe_of, dupe) for (dupe_of, dupe) in self.cursor.fetchall()]
210
 
 
211
 
class Bug:
212
 
    """Representation of a Bugzilla Bug"""
213
 
    def __init__(self, backend, bug_id):
214
 
        self.backend = backend
215
 
        (self.bug_id, self.assigned_to, self.bug_file_loc, self.bug_severity,
216
 
         self.bug_status, self.creation_ts, self.short_desc, self.op_sys,
217
 
         self.priority, self.product, self.rep_platform, self.reporter,
218
 
         self.version, self.component, self.resolution,
219
 
         self.target_milestone, self.qa_contact, self.status_whiteboard,
220
 
         self.keywords, self.alias) = backend.getBugInfo(bug_id)
221
 
 
222
 
        self._ccs = None
223
 
        self._comments = None
224
 
        self._attachments = None
225
 
 
226
 
    @property
227
 
    def ccs(self):
228
 
        """Return the IDs of people CC'd to this bug"""
229
 
        if self._ccs is not None:
230
 
            return self._ccs
231
 
        self._ccs = self.backend.getBugCcs(self.bug_id)
232
 
        return self._ccs
233
 
 
234
 
    @property
235
 
    def comments(self):
236
 
        """Return the comments attached to this bug"""
237
 
        if self._comments is not None:
238
 
            return self._comments
239
 
        self._comments = self.backend.getBugComments(self.bug_id)
240
 
        return self._comments
241
 
 
242
 
    @property
243
 
    def attachments(self):
244
 
        """Return the attachments for this bug"""
245
 
        if self._attachments is not None:
246
 
            return self._attachments
247
 
        self._attachments = self.backend.getBugAttachments(self.bug_id)
248
 
        return self._attachments
249
 
 
250
 
    def mapSeverity(self, bugtask):
251
 
        """Set a Launchpad bug task's importance based on this bug's severity.
252
 
        """
253
 
        bug_importer = getUtility(ILaunchpadCelebrities).bug_importer
254
 
        importance_map = {
255
 
            'blocker': BugTaskImportance.CRITICAL,
256
 
            'critical': BugTaskImportance.CRITICAL,
257
 
            'major': BugTaskImportance.HIGH,
258
 
            'normal': BugTaskImportance.MEDIUM,
259
 
            'minor': BugTaskImportance.LOW,
260
 
            'trivial': BugTaskImportance.LOW,
261
 
            'enhancement': BugTaskImportance.WISHLIST
262
 
            }
263
 
        importance = importance_map.get(
264
 
            self.bug_severity, BugTaskImportance.UNKNOWN)
265
 
        bugtask.transitionToImportance(importance, bug_importer)
266
 
 
267
 
    def mapStatus(self, bugtask):
268
 
        """Set a Launchpad bug task's status based on this bug's status.
269
 
 
270
 
        If the bug is in the RESOLVED, VERIFIED or CLOSED states, the
271
 
        bug resolution is also taken into account when mapping the
272
 
        status.
273
 
 
274
 
        Additional information about the bugzilla status is appended
275
 
        to the bug task's status explanation.
276
 
        """
277
 
        bug_importer = getUtility(ILaunchpadCelebrities).bug_importer
278
 
 
279
 
        if self.bug_status == 'ASSIGNED':
280
 
            bugtask.transitionToStatus(
281
 
                BugTaskStatus.CONFIRMED, bug_importer)
282
 
        elif self.bug_status == 'NEEDINFO':
283
 
            bugtask.transitionToStatus(
284
 
                BugTaskStatus.INCOMPLETE, bug_importer)
285
 
        elif self.bug_status == 'PENDINGUPLOAD':
286
 
            bugtask.transitionToStatus(
287
 
                BugTaskStatus.FIXCOMMITTED, bug_importer)
288
 
        elif self.bug_status in ['RESOLVED', 'VERIFIED', 'CLOSED']:
289
 
            # depends on the resolution:
290
 
            if self.resolution == 'FIXED':
291
 
                bugtask.transitionToStatus(
292
 
                    BugTaskStatus.FIXRELEASED, bug_importer)
293
 
            else:
294
 
                bugtask.transitionToStatus(
295
 
                    BugTaskStatus.INVALID, bug_importer)
296
 
        else:
297
 
            bugtask.transitionToStatus(
298
 
                BugTaskStatus.NEW, bug_importer)
299
 
 
300
 
        # add the status to the notes section, to account for any lost
301
 
        # information
302
 
        bugzilla_status = 'Bugzilla status=%s' % self.bug_status
303
 
        if self.resolution:
304
 
            bugzilla_status += ' %s' % self.resolution
305
 
        bugzilla_status += ', product=%s' % self.product
306
 
        bugzilla_status += ', component=%s' % self.component
307
 
 
308
 
        if bugtask.statusexplanation:
309
 
            bugtask.statusexplanation = '%s (%s)' % (
310
 
                bugtask.statusexplanation, bugzilla_status)
311
 
        else:
312
 
            bugtask.statusexplanation = bugzilla_status
313
 
 
314
 
 
315
 
class Bugzilla:
316
 
    """Representation of a bugzilla instance"""
317
 
 
318
 
    def __init__(self, conn):
319
 
        if conn is not None:
320
 
            self.backend = BugzillaBackend(conn)
321
 
        else:
322
 
            self.backend = None
323
 
        self.ubuntu = getUtility(ILaunchpadCelebrities).ubuntu
324
 
        self.debian = getUtility(ILaunchpadCelebrities).debian
325
 
        self.bugtracker = getUtility(ILaunchpadCelebrities).ubuntu_bugzilla
326
 
        self.debbugs = getUtility(ILaunchpadCelebrities).debbugs
327
 
        self.bugset = getUtility(IBugSet)
328
 
        self.bugtaskset = getUtility(IBugTaskSet)
329
 
        self.bugwatchset = getUtility(IBugWatchSet)
330
 
        self.cveset = getUtility(ICveSet)
331
 
        self.personset = getUtility(IPersonSet)
332
 
        self.emailset = getUtility(IEmailAddressSet)
333
 
        self.person_mapping = {}
334
 
 
335
 
    def person(self, bugzilla_id):
336
 
        """Get the Launchpad person corresponding to the given Bugzilla ID"""
337
 
        # Bugzilla treats a user ID of 0 as a NULL
338
 
        if bugzilla_id == 0:
339
 
            return None
340
 
 
341
 
        # Try and get the person using a cache of the mapping.  We
342
 
        # check to make sure the person still exists and has not been
343
 
        # merged.
344
 
        person = None
345
 
        launchpad_id = self.person_mapping.get(bugzilla_id)
346
 
        if launchpad_id is not None:
347
 
            person = self.personset.get(launchpad_id)
348
 
            if person is not None and person.merged is not None:
349
 
                person = None
350
 
 
351
 
        # look up the person
352
 
        if person is None:
353
 
            email, displayname = self.backend.lookupUser(bugzilla_id)
354
 
 
355
 
            person = self.personset.ensurePerson(
356
 
                email, displayname, PersonCreationRationale.BUGIMPORT,
357
 
                comment=('when importing bugs from %s'
358
 
                         % self.bugtracker.baseurl))
359
 
 
360
 
            # Bugzilla performs similar address checks to Launchpad, so
361
 
            # if the Launchpad account has no preferred email, use the
362
 
            # Bugzilla one.
363
 
            emailaddr = self.emailset.getByEmail(email)
364
 
            assert emailaddr is not None
365
 
            if person.preferredemail != emailaddr:
366
 
                person.validateAndEnsurePreferredEmail(emailaddr)
367
 
 
368
 
            self.person_mapping[bugzilla_id] = person.id
369
 
 
370
 
        return person
371
 
 
372
 
    def _getPackageNames(self, bug):
373
 
        """Returns the source and binary package names for the given bug."""
374
 
        # we currently only support mapping Ubuntu bugs ...
375
 
        if bug.product != 'Ubuntu':
376
 
            raise AssertionError('product must be Ubuntu')
377
 
 
378
 
        # kernel bugs are currently filed against the "linux"
379
 
        # component, which is not a source or binary package.  The
380
 
        # following mapping was provided by BenC:
381
 
        if bug.component == 'linux':
382
 
            cutoffdate = datetime.datetime(2004, 12, 1,
383
 
                                           tzinfo=pytz.timezone('UTC'))
384
 
            if bug.bug_status == 'NEEDINFO' and bug.creation_ts < cutoffdate:
385
 
                pkgname = 'linux-source-2.6.12'
386
 
            else:
387
 
                pkgname = 'linux-source-2.6.15'
388
 
        else:
389
 
            pkgname = bug.component.encode('ASCII')
390
 
 
391
 
        try:
392
 
            srcpkg, binpkg = self.ubuntu.guessPackageNames(pkgname)
393
 
        except NotFoundError, e:
394
 
            logger.warning('could not find package name for "%s": %s',
395
 
                           pkgname, str(e))
396
 
            srcpkg = binpkg = None
397
 
 
398
 
        return srcpkg, binpkg
399
 
 
400
 
    def getLaunchpadBugTarget(self, bug):
401
 
        """Returns a dictionary of arguments to createBug() that correspond
402
 
        to the given bugzilla bug.
403
 
        """
404
 
        srcpkg, binpkg = self._getPackageNames(bug)
405
 
        return {
406
 
            'distribution': self.ubuntu,
407
 
            'sourcepackagename': srcpkg,
408
 
            }
409
 
 
410
 
    def getLaunchpadMilestone(self, bug):
411
 
        """Return the Launchpad milestone for a Bugzilla bug.
412
 
 
413
 
        If the milestone does not exist, then it is created.
414
 
        """
415
 
        if bug.product != 'Ubuntu':
416
 
            raise AssertionError('product must be Ubuntu')
417
 
 
418
 
        # Bugzilla uses a value of "---" to represent "no selected Milestone"
419
 
        # Launchpad represents this by setting the milestone column to NULL.
420
 
        if bug.target_milestone is None or bug.target_milestone == '---':
421
 
            return None
422
 
 
423
 
        # generate a Launchpad name from the Milestone name:
424
 
        name = re.sub(r'[^a-z0-9\+\.\-]', '-', bug.target_milestone.lower())
425
 
 
426
 
        milestone = self.ubuntu.getMilestone(name)
427
 
        if milestone is None:
428
 
            milestone = self.ubuntu.currentseries.newMilestone(name)
429
 
            Store.of(milestone).flush()
430
 
        return milestone
431
 
 
432
 
    def getLaunchpadUpstreamProduct(self, bug):
433
 
        """Find the upstream product for the given Bugzilla bug.
434
 
 
435
 
        This function relies on the package -> product linkage having been
436
 
        entered in advance.
437
 
        """
438
 
        srcpkgname, binpkgname = self._getPackageNames(bug)
439
 
        # find a product series
440
 
        series = None
441
 
        for series in self.ubuntu.series:
442
 
            srcpkg = series.getSourcePackage(srcpkgname)
443
 
            if srcpkg:
444
 
                series = srcpkg.productseries
445
 
                if series:
446
 
                    return series.product
447
 
        else:
448
 
            logger.warning('could not find upstream product for '
449
 
                           'source package "%s"', srcpkgname.name)
450
 
            return None
451
 
 
452
 
    _bug_re = re.compile('bug\s*#?\s*(?P<id>\d+)', re.IGNORECASE)
453
 
    def replaceBugRef(self, match):
454
 
        # XXX: jamesh 2005-10-24:
455
 
        # this is where bug number rewriting would be plugged in
456
 
        bug_id = int(match.group('id'))
457
 
        url = '%s/%d' % (canonical_url(self.bugtracker), bug_id)
458
 
        return '%s [%s]' % (match.group(0), url)
459
 
 
460
 
 
461
 
    def handleBug(self, bug_id):
462
 
        """Maybe import a single bug.
463
 
 
464
 
        If the bug has already been imported (detected by checking for
465
 
        a bug watch), it is skipped.
466
 
        """
467
 
        logger.info('Handling Bugzilla bug %d', bug_id)
468
 
 
469
 
        # is there a bug watch on the bug?
470
 
        lp_bug = self.bugset.queryByRemoteBug(self.bugtracker, bug_id)
471
 
 
472
 
        # if we already have an associated bug, don't add a new one.
473
 
        if lp_bug is not None:
474
 
            logger.info('Bugzilla bug %d is already being watched by '
475
 
                        'Launchpad bug %d', bug_id, lp_bug.id)
476
 
            return lp_bug
477
 
 
478
 
        bug = Bug(self.backend, bug_id)
479
 
 
480
 
        comments = bug.comments[:]
481
 
 
482
 
        # create a message for the initial comment:
483
 
        msgset = getUtility(IMessageSet)
484
 
        who, when, text = comments.pop(0)
485
 
        text = self._bug_re.sub(self.replaceBugRef, text)
486
 
        # If a URL is associated with the bug, add it to the description:
487
 
        if bug.bug_file_loc:
488
 
            text = text + '\n\n' + bug.bug_file_loc
489
 
        # the initial comment can't be empty:
490
 
        if not text.strip():
491
 
            text = '<empty comment>'
492
 
        msg = msgset.fromText(bug.short_desc, text, self.person(who), when)
493
 
 
494
 
        # create the bug
495
 
        target = self.getLaunchpadBugTarget(bug)
496
 
        params = CreateBugParams(
497
 
            msg=msg, datecreated=bug.creation_ts, title=bug.short_desc,
498
 
            owner=self.person(bug.reporter))
499
 
        params.setBugTarget(**target)
500
 
        lp_bug = self.bugset.createBug(params)
501
 
 
502
 
        # add the bug watch:
503
 
        lp_bug.addWatch(self.bugtracker, str(bug.bug_id), lp_bug.owner)
504
 
 
505
 
        # add remaining comments, and add CVEs found in all text
506
 
        lp_bug.findCvesInText(text, lp_bug.owner)
507
 
        for (who, when, text) in comments:
508
 
            text = self._bug_re.sub(self.replaceBugRef, text)
509
 
            msg = msgset.fromText(msg.followup_title, text,
510
 
                                  self.person(who), when)
511
 
            lp_bug.linkMessage(msg)
512
 
 
513
 
        # subscribe QA contact and CC's
514
 
        if bug.qa_contact:
515
 
            lp_bug.subscribe(
516
 
                self.person(bug.qa_contact), self.person(bug.reporter))
517
 
        for cc in bug.ccs:
518
 
            lp_bug.subscribe(self.person(cc), self.person(bug.reporter))
519
 
 
520
 
        # translate bugzilla status and severity to LP equivalents
521
 
        task = lp_bug.bugtasks[0]
522
 
        task.datecreated = bug.creation_ts
523
 
        task.transitionToAssignee(self.person(bug.assigned_to))
524
 
        task.statusexplanation = bug.status_whiteboard
525
 
        bug.mapSeverity(task)
526
 
        bug.mapStatus(task)
527
 
 
528
 
        # bugs with an alias of the form "deb1234" have been imported
529
 
        # from the Debian bug tracker by the "debzilla" program.  For
530
 
        # these bugs, generate a task and watch on the corresponding
531
 
        # bugs.debian.org bug.
532
 
        if bug.alias:
533
 
            if re.match(r'^deb\d+$', bug.alias):
534
 
                watch = self.bugwatchset.createBugWatch(
535
 
                    lp_bug, lp_bug.owner, self.debbugs, bug.alias[3:])
536
 
                debtask = self.bugtaskset.createTask(
537
 
                    lp_bug,
538
 
                    owner=lp_bug.owner,
539
 
                    distribution=self.debian,
540
 
                    sourcepackagename=target['sourcepackagename'])
541
 
                debtask.datecreated = bug.creation_ts
542
 
                debtask.bugwatch = watch
543
 
            else:
544
 
                # generate a Launchpad name from the alias:
545
 
                name = re.sub(r'[^a-z0-9\+\.\-]', '-', bug.alias.lower())
546
 
                lp_bug.name = name
547
 
 
548
 
        # for UPSTREAM bugs, try to find whether the URL field contains
549
 
        # a bug reference.
550
 
        if bug.bug_status == 'UPSTREAM':
551
 
            # see if the URL field contains a bug tracker reference
552
 
            watches = self.bugwatchset.fromText(bug.bug_file_loc,
553
 
                                                lp_bug, lp_bug.owner)
554
 
            # find the upstream product for this bug
555
 
            product = self.getLaunchpadUpstreamProduct(bug)
556
 
 
557
 
            # if we created a watch, and there is an upstream product,
558
 
            # create a new task and link it to the watch.
559
 
            if len(watches) > 0:
560
 
                if product:
561
 
                    upstreamtask = self.bugtaskset.createTask(
562
 
                        lp_bug, product=product, owner=lp_bug.owner)
563
 
                    upstreamtask.datecreated = bug.creation_ts
564
 
                    upstreamtask.bugwatch = watches[0]
565
 
                else:
566
 
                    logger.warning('Could not find upstream product to link '
567
 
                                   'bug %d to', lp_bug.id)
568
 
 
569
 
        # translate milestone linkage
570
 
        task.milestone = self.getLaunchpadMilestone(bug)
571
 
 
572
 
        # import attachments
573
 
        for (attach_id, creation_ts, description, mimetype, ispatch,
574
 
             filename, thedata, submitter_id) in bug.attachments:
575
 
            # if the filename is missing for some reason, use a generic one.
576
 
            if filename is None or filename.strip() == '':
577
 
                filename = 'untitled'
578
 
            logger.debug('Creating attachment %s for bug %d',
579
 
                         filename, bug.bug_id)
580
 
            if ispatch:
581
 
                attach_type = BugAttachmentType.PATCH
582
 
                mimetype = 'text/plain'
583
 
            else:
584
 
                attach_type = BugAttachmentType.UNSPECIFIED
585
 
 
586
 
            # look for a message starting with "Created an attachment (id=NN)"
587
 
            for msg in lp_bug.messages:
588
 
                if msg.text_contents.startswith(
589
 
                        'Created an attachment (id=%d)' % attach_id):
590
 
                    break
591
 
            else:
592
 
                # could not find the add message, so create one:
593
 
                msg = msgset.fromText(description,
594
 
                                      'Created attachment %s' % filename,
595
 
                                      self.person(submitter_id),
596
 
                                      creation_ts)
597
 
                lp_bug.linkMessage(msg)
598
 
 
599
 
            filealias = getUtility(ILibraryFileAliasSet).create(
600
 
                name=filename,
601
 
                size=len(thedata),
602
 
                file=StringIO(thedata),
603
 
                contentType=mimetype)
604
 
 
605
 
            getUtility(IBugAttachmentSet).create(
606
 
                bug=lp_bug, filealias=filealias, attach_type=attach_type,
607
 
                title=description, message=msg)
608
 
 
609
 
        return lp_bug
610
 
 
611
 
    def processDuplicates(self, trans):
612
 
        """Mark Launchpad bugs as duplicates based on Bugzilla duplicates.
613
 
 
614
 
        Launchpad bug A will be marked as a duplicate of bug B if:
615
 
         * bug A watches bugzilla bug A'
616
 
         * bug B watches bugzilla bug B'
617
 
         * bug A' is a duplicate of bug B'
618
 
         * bug A is not currently a duplicate of any other bug.
619
 
        """
620
 
        logger.info('Processing duplicate bugs')
621
 
        bugmap = {}
622
 
        def getlpbug(bugid):
623
 
            """Get the Launchpad bug corresponding to the given remote ID
624
 
 
625
 
            This function makes use of a cache dictionary to reduce the
626
 
            number of lookups.
627
 
            """
628
 
            lpbugid = bugmap.get(bugid)
629
 
            if lpbugid is not None:
630
 
                if lpbugid != 0:
631
 
                    lpbug = self.bugset.get(lpbugid)
632
 
                else:
633
 
                    lpbug = None
634
 
            else:
635
 
                lpbug = self.bugset.queryByRemoteBug(self.bugtracker, bugid)
636
 
                if lpbug is not None:
637
 
                    bugmap[bugid] = lpbug.id
638
 
                else:
639
 
                    bugmap[bugid] = 0
640
 
            return lpbug
641
 
 
642
 
        for (dupe_of, dupe) in self.backend.getDuplicates():
643
 
            # get the Launchpad bugs corresponding to the two Bugzilla bugs:
644
 
            trans.begin()
645
 
            lpdupe_of = getlpbug(dupe_of)
646
 
            lpdupe = getlpbug(dupe)
647
 
            # if both bugs exist in Launchpad, and lpdupe is not already
648
 
            # a duplicate, mark it as a duplicate of lpdupe_of.
649
 
            if (lpdupe_of is not None and lpdupe is not None and
650
 
                lpdupe.duplicateof is None):
651
 
                logger.info('Marking %d as a duplicate of %d',
652
 
                            lpdupe.id, lpdupe_of.id)
653
 
                lpdupe.markAsDuplicate(lpdupe_of)
654
 
            trans.commit()
655
 
 
656
 
    def importBugs(self, trans, product=None, component=None, status=None):
657
 
        """Import Bugzilla bugs matching the given constraints.
658
 
 
659
 
        Each of product, component and status gives a list of
660
 
        products, components or statuses to limit the import to.  An
661
 
        empty list matches all products, components or statuses.
662
 
        """
663
 
        if product is None:
664
 
            product = []
665
 
        if component is None:
666
 
            component = []
667
 
        if status is None:
668
 
            status = []
669
 
 
670
 
        bugs = self.backend.findBugs(product=product,
671
 
                                     component=component,
672
 
                                     status=status)
673
 
        for bug_id in bugs:
674
 
            trans.begin()
675
 
            try:
676
 
                self.handleBug(bug_id)
677
 
            except (SystemExit, KeyboardInterrupt):
678
 
                raise
679
 
            except:
680
 
                logger.exception('Could not import Bugzilla bug #%d', bug_id)
681
 
                trans.abort()
682
 
            else:
683
 
                trans.commit()