Edgewall Software

source: trunk/contrib/sourceforge2trac.py

Last change on this file was 12788, checked in by rjollos, 14 months ago

1.1.2dev: Replace print statements with print_function from __future__ module. Refs #11600.

  • Property svn:eol-style set to native
File size: 24.6 KB
Line 
1# -*- coding: utf-8 -*-
2#
3# Copyright (C) 2004-2013 Edgewall Software
4# Copyright (C) 2004 Dmitry Yusupov <dmitry_yus@yahoo.com>
5# Copyright (C) 2004 Mark Rowe <mrowe@bluewire.net.nz>
6# Copyright (C) 2010 Anatoly Techtonik <techtonik@php.net>
7# All rights reserved.
8#
9# This software is licensed as described in the file COPYING, which
10# you should have received as part of this distribution. The terms
11# are also available at http://trac.edgewall.com/license.html.
12#
13# This software consists of voluntary contributions made by many
14# individuals. For the exact contribution history, see the revision
15# history and logs, available at http://trac.edgewall.org/.
16
17"""
18Import a Sourceforge project's tracker items into a Trac database.
19
20Requires:
21   Trac 1.0 from http://trac.edgewall.org/
22   Python 2.5 from http://www.python.org/
23
241.0 clean-up by cboos **untested**, use at your own risks and send patches
25
26The Sourceforge tracker items can be exported from the 'Backup' page
27of the project admin section. Substitute XXXXX with project id:
28https://sourceforge.net/export/xml_export2.php?group_id=XXXXX
29
30$Id$
31
32
33Uses Trac 0.11 DB format version 21
34SourceForge XML Export format identified by the header:
35<!DOCTYPE project_export SYSTEM "http://sourceforge.net/export/sf_project_export_0.2.dtd">
36
37Works with all DB backends. Attachments are not downloaded, but inserted
38as links to SF tracker.
39
40
41Ticket Types, Priorities and Resolutions
42----------------------------------------
43Conversion kills default Trac ticket types:
44- defect      1
45- enhancement 2
46- task        3
47
48and priorities:
49- blocker  1
50- critical 2
51- major    3
52- minor    4
53- trivial  5
54
55and resolutions:
56- fixed      1
57- invalid    2
58- wontfix    3
59- duplicate  4
60- worksforme 5
61
62
63Versions and Milestones
64-----------------------
65Kills versions and milestones from existing Trac DB
66
67
68Mapping
69-------
70tracker_name == ticket_type
71group_name == version
72category_name == component
73
74user nobody == anonymous
75
76
77Not implemented (feature:reason)
78--------------------------------
79attachments:made as a comment with links to attachments stored on SF
80            (type,id,filename,size,time,description,author,ipnr)
81ticket_custom:unknown (ticket,name,value)
82history:imported only for summary, priority. closed date and owner fields
83
84severities:no field in source data
85"""
86
87
88#: rename users from SF to Trac
89user_map = {"nobody":"anonymous"}
90
91
92
93complete_msg = """
94Conversion complete.
95
96You may want to login into Trac to verify names for ticket owners. You may
97also want to rename ticket types and priorities to default.
98"""
99
100from xml.etree.ElementTree import ElementTree
101import time
102import sys
103
104import trac.env
105
106# --- utility
107class DBNotEmpty(Exception):
108    def __str__(self):
109        return "Will not modify database with existing tickets!"
110
111class FlatXML(object):
112    """Flat XML is XML without element attributes. Also each element
113       may contain other elements or text, but not both.
114
115       This object mirrors XML structure into own properties for convenient
116       access to tree elements, i.e. flat.trackers[2].groups[2].group_name
117
118       Uses recursion.
119    """
120
121    def __init__(self, el=None):
122        """el is ElementTree element"""
123        if el:
124            self.merge(el)
125
126    def merge(self, el):
127        """merge supplied ElementTree element into current object"""
128        for c in el:
129            if len(c.getchildren()) == 0:
130                if c.text != None and len(c.text.strip()) != 0:
131                    self.__setattr__(c.tag, c.text)
132                else:
133                    self.__setattr__(c.tag, [])
134            else: #if c.getchildren()[0].tag == c.tag[:-1]:
135                # c is a set of elements
136                self.__setattr__(c.tag, [FlatXML(x) for x in c.getchildren()])
137
138
139    def __str__(self):
140        buf = ""
141        for sub in self.__dict__:
142            val = self.__dict__[sub]
143            if type(val) != list:
144                buf += "%s : %s\n" % (sub, val)
145            else:
146                for x in val:
147                    buf += "\n  ".join(x.__str__().split("\n"))
148        return buf
149
150    def __repr__(self):
151        buf = ""
152        for sub in self.__dict__:
153            val = self.__dict__[sub]
154            if type(val) != list:
155                buf += "<%s>%s</%s>\n" % (sub, val, sub)
156            else:
157                for x in val:
158                    buf += "\n  ".join(x.__repr__().split("\n"))
159        return buf
160
161
162# --- SF data model
163class Tracker(FlatXML):
164    """
165 <trackers>
166  <tracker>
167   <url>http://sourceforge.net/?group_id=175454&#38;atid=873299</url>
168   <tracker_id>873299</tracker_id>
169   <name>Bugs</name>
170   <description>Bug Tracking System</description>
171   <is_public>All site users</is_public>
172   <allow_anon>Yes</allow_anon>
173   <email_updates>Send to goblinhack@gmail.com</email_updates>
174   <due_period>2592000</due_period>
175   <submit_instructions></submit_instructions>
176   <browse_instructions></browse_instructions>
177   <status_timeout>1209600</status_timeout>
178   <due_period_initial>0</due_period_initial>
179   <due_period_update>0</due_period_update>
180   <reopen_on_comment>1</reopen_on_comment>
181   <canned_responses>
182   </canned_responses>
183   <groups>
184    <group>
185     <id>632324</id>
186      <group_name>v1.0 (example)</group_name>
187    </group>
188   </groups>
189   <categories>
190    <category>
191     <id>885178</id>
192      <category_name>Interface (example)</category_name>
193     <auto_assignee>nobody</auto_assignee>
194    </category>
195   </categories>
196   <resolutions>
197    <resolution>
198     <id>1</id>
199     <name>Fixed</name>
200    </resolution>
201    <resolution>
202     <id>2</id>
203     <name>Invalid</name>
204    </resolution>
205    ...
206   </resolutions>
207   <statuses>
208    <status>
209      <id>1</id>
210      <name>Open</name>
211    </status>
212    <status>
213      <id>2</id>
214      <name>Closed</name>
215    </status>
216    <status>
217      <id>3</id>
218      <name>Deleted</name>
219    </status>
220    <status>
221      <id>4</id>
222      <name>Pending</name>
223    </status>
224   </statuses>
225   ...
226   <tracker_items>
227    <tracker_item>
228<url>http://sourceforge.net/support/tracker.php?aid=2471428</url>
229<id>2471428</id>
230<status_id>2</status_id>
231<category_id>100</category_id>
232<group_id>100</group_id>
233<resolution_id>100</resolution_id>
234<submitter>sbluen</submitter>
235<assignee>nobody</assignee>
236<closer>goblinhack</closer>
237<submit_date>1230400444</submit_date>
238<close_date>1231087612</close_date>
239<priority>5</priority>
240<summary>glitch with edge of level</summary>
241<details>The mini-laser that the future soldier carries is so powerful that it even lets me go outside the level. I stand at the top edge of the level and then shoot up, and then it gets me somewhere where I am not supposed to go.</details>
242<is_private>0</is_private>
243<followups>
244 <followup>
245  <id>2335316</id>
246  <submitter>goblinhack</submitter>
247  <date>1175610236</date>
248  <details>Logged In: YES
249  user_id=1577972
250  Originator: NO
251
252  does this happen every game or just once?
253
254  you could send me the saved file and I'll try and load it - old
255  versions harldy ever work with newer versions - need to add some
256  kind of warnings on that
257
258  tx</details>
259 </followup>
260 ...
261</followups>
262<attachments>
263 <attachment>
264  <url>http://sourceforge.net/tracker/download.php?group_id=175454&#38;atid=873299&#38;file_id=289080&#38;aid=</url>
265  <id>289080</id>
266  <filename>your_most_recent_game.gz</filename>
267  <description>my saved game</description>
268  <filesize>112968</filesize>
269  <filetype>application/x-gzip</filetype>
270  <date>1218987770</date>
271  <submitter>sbluen</submitter>
272 </attachment>
273...
274</attachments>
275<history_entries>
276 <history_entry>
277  <id>7304242</id>
278  <field_name>IP</field_name>
279  <old_value>Artifact Created: 76.173.48.148</old_value>
280  <date>1230400444</date>
281  <updator>sbluen</updator>
282 </history_entry>
283 ...
284</history_entries>
285    </tracker_item>
286    ...
287   </tracker_items>
288  ...
289  </tracker>
290 </trackers>
291    """
292    def __init__(self, e):
293        self.merge(e)
294
295
296class ExportedProjectData(object):
297    """Project data container as Python object.
298    """
299    def __init__(self, f):
300        """Data parsing"""
301
302        self.trackers = []    #: tracker properties and data
303        self.groups = []      #: groups []
304        self.priorities = []  #: priorities used
305        self.resolutions = [] #: resolutions (index, name)
306        self.tickets = []     #: all tickets
307        self.statuses = []    #: status (idx, name)
308
309        self.used_resolutions = {} #: id:name
310        self.used_categories  = {} #: id:name
311        # id '100' means no category
312        self.used_categories['100'] = None
313        self.users = {}       #: id:name
314
315        root = ElementTree().parse(f)
316
317        self.users = dict([(FlatXML(u).userid, FlatXML(u).username)
318                          for u in root.find('referenced_users')])
319
320        for tracker in root.find('trackers'):
321            tr = Tracker(tracker)
322            self.trackers.append(tr)
323
324            # groups-versions
325            for grp in tr.groups:
326                # group ids are tracker-specific even if names match
327                g = (grp.id, grp.group_name)
328                if g not in self.groups:
329                    self.groups.append(g)
330
331            # resolutions
332            for res in tr.resolutions:
333                r = (res.id, res.name)
334                if r not in self.resolutions:
335                    self.resolutions.append(r)
336
337            # statuses
338            self.statuses = [(s.id, s.name) for s in tr.statuses]
339
340            # tickets
341            for tck in tr.tracker_items:
342                if type(tck) == str:
343                    print(repr(tck))
344                self.tickets.append(tck)
345                if int(tck.priority) not in self.priorities:
346                    self.priorities.append(int(tck.priority))
347                res_id = getattr(tck, "resolution_id", None)
348                if res_id is not None and res_id not in self.used_resolutions:
349                    for idx, name in self.resolutions:
350                        if idx == res_id: break
351                    self.used_resolutions[res_id] = \
352                            dict(self.resolutions)[res_id]
353                # used categories
354                categories = dict(self.get_categories(tr, noowner=True))
355                if tck.category_id not in self.used_categories:
356                    self.used_categories[tck.category_id] = \
357                            categories[tck.category_id]
358
359        # sorting everything
360        self.trackers.sort(key=lambda x:x.name)
361        self.groups.sort()
362        self.priorities.sort()
363
364    def get_categories(self, tracker=None, noid=False, noowner=False):
365        """ SF categories : Trac components
366            (id, name, owner) tuples for specified tracker or all trackers
367            if noid or noowner flags are set, specified tuple attribute is
368            stripped
369        """
370        trs = [tracker] if tracker is not None else self.trackers
371        categories = []
372        for tr in trs:
373            for cat in tr.categories:
374                c = (cat.id, cat.category_name, cat.auto_assignee)
375                if c not in categories:
376                    categories.append(c)
377        #: sort by name
378        if noid:
379            categories.sort()
380        else:
381            categories.sort(key=lambda x:x[1])
382        if noowner:
383            categories = [x[:2] for x in categories]
384        if noid:
385            categories = [x[1:] for x in categories]
386        return categories
387
388
389class TracDatabase(object):
390    def __init__(self, path):
391        self.env = trac.env.Environment(path)
392
393    def hasTickets(self):
394        return int(self.env.db_query("SELECT count(*) FROM ticket")[0][0]) > 0
395
396    def dbCheck(self):
397        if self.hasTickets():
398            raise DBNotEmpty
399
400    def setTypeList(self, s):
401        """Remove all types, set them to `s`"""
402        self.dbCheck()
403        with self.env.db_transaction as db:
404            db("DELETE FROM enum WHERE type='ticket_type'")
405            for i, value in enumerate(s):
406                db("INSERT INTO enum (type, name, value) VALUES (%s, %s, %s)",
407                   ("ticket_type", value, i))
408
409    def setPriorityList(self, s):
410        """Remove all priorities, set them to `s`"""
411        self.dbCheck()
412        with self.env.db_transaction as db:
413            db("DELETE FROM enum WHERE type='priority'")
414            for i, value in enumerate(s):
415                db("INSERT INTO enum (type, name, value) VALUES (%s, %s, %s)",
416                   ("priority", value, i))
417
418    def setResolutionList(self, t):
419        """Remove all resolutions, set them to `t` (index, name)"""
420        self.dbCheck()
421        with self.env.db_transaction as db:
422            db("DELETE FROM enum WHERE type='resolution'")
423            for value, name in t:
424                db("INSERT INTO enum (type, name, value) VALUES (%s, %s, %s)",
425                   ("resolution", name, value))
426
427    def setComponentList(self, t):
428        """Remove all components, set them to `t` (name, owner)"""
429        self.dbCheck()
430        with self.env.db_transaction as db:
431            db("DELETE FROM component")
432            for name, owner in t:
433                db("INSERT INTO component (name, owner) VALUES (%s, %s)",
434                   (name, owner))
435
436    def setVersionList(self, v):
437        """Remove all versions, set them to `v`"""
438        self.dbCheck()
439        with self.env.db_transaction as db:
440            db("DELETE FROM version")
441            for value in v:
442                # time and description are also available
443                db("INSERT INTO version (name) VALUES (%s)", value)
444
445    def setMilestoneList(self, m):
446        """Remove all milestones, set them to `m` ("""
447        self.dbCheck()
448        with self.env.db_transaction as db:
449            db("DELETE FROM milestone")
450            for value in m:
451                # due, completed, description are also available
452                db("INSERT INTO milestone (name) VALUES (%s)", value)
453
454    def addTicket(self, type, time, changetime, component,
455                  priority, owner, reporter, cc,
456                  version, milestone, status, resolution,
457                  summary, description, keywords):
458        """ ticket table db21.py format
459
460        id              integer PRIMARY KEY,
461        type            text,           -- the nature of the ticket
462        time            integer,        -- the time it was created
463        changetime      integer,
464        component       text,
465        severity        text,
466        priority        text,
467        owner           text,           -- who is this ticket assigned to
468        reporter        text,
469        cc              text,           -- email addresses to notify
470        version         text,           --
471        milestone       text,           --
472        status          text,
473        resolution      text,
474        summary         text,           -- one-line summary
475        description     text,           -- problem description (long)
476        keywords        text
477        """
478        if status.lower() == 'open':
479            if owner != '':
480                status = 'assigned'
481            else:
482                status = 'new'
483
484        with self.env.db_transaction as db:
485            c = db.cursor()
486            c.execute("""
487                INSERT INTO ticket (type, time, changetime, component,
488                                    priority, owner, reporter, cc, version,
489                                    milestone, status, resolution, summary,
490                                    description, keywords)
491                VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s,
492                        %s, %s)
493                """, (type, time, changetime, component, priority, owner,
494                      reporter, cc, version, milestone, status.lower(),
495                      resolution, summary, '%s' % description, keywords))
496            return db.get_last_id(c, 'ticket')
497
498    def addTicketComment(self, ticket, time, author, value):
499        with self.env.db_transaction as db:
500            db("""
501                INSERT INTO ticket_change (ticket, time, author, field,
502                                           oldvalue, newvalue)
503                VALUES (%s, %s, %s, %s, %s, %s)
504                """, (ticket, time, author, 'comment', '', '%s' % value))
505
506    def addTicketChange(self, ticket, time, author, field, oldvalue, newvalue):
507        with self.env.db_transaction as db:
508            db("""INSERT INTO ticket_change (ticket, time, author, field,
509                                             oldvalue, newvalue)
510                  VALUES (%s, %s, %s, %s, %s, %s)
511                  """, (ticket, time, author, field, oldvalue, newvalue))
512
513
514def importData(f, env, opt):
515    project = ExportedProjectData(f)
516    trackers = project.trackers
517
518    trac = TracDatabase(env)
519
520    # Data conversion
521    typeList = [x.name for x in trackers]
522    print("%d trackers will be converted to the following ticket types:\n  %s" \
523        % (len(trackers), typeList))
524
525    used_cat_names = set(project.used_categories.values())
526    #: make names unique, forget about competing owners (the last one wins)
527    components = dict(project.get_categories(noid=True)).items()
528    components.sort()
529    components = [x for x in components if x[0] in used_cat_names]
530    print("%d out of %d categories are used and will be converted to the"
531          " following components:\n  %s"
532          % (len(components), len(project.get_categories()), components))
533    print("..renaming component owners:")
534    for i,c in enumerate(components):
535        if c[1] in user_map:
536            components[i] = (c[0], user_map[c[1]])
537    print(%s" % components)
538
539    print("%d groups which will be converted to the following versions:\n"
540          %s" % (len(project.groups), project.groups))
541    print("%d resolutions found :\n  %s"
542          % (len(project.resolutions), project.resolutions))
543    resolutions = [(k,project.used_resolutions[k])
544                   for k in project.used_resolutions]
545    resolutions.sort(key=lambda x:int(x[0]))
546    print(".. only %d used will be imported:\n  %s"
547          % (len(resolutions), resolutions))
548    print("Priorities used so far: %s" % project.priorities)
549    if not(raw_input("Continue [y/N]?").lower() == 'y'):
550        sys.exit()
551
552    # Data save
553    trac.setTypeList(typeList)
554    trac.setComponentList(components)
555    trac.setPriorityList(range(min(project.priorities),
556                               max(project.priorities)))
557    trac.setVersionList(set([x[1] for x in project.groups]))
558    trac.setResolutionList(resolutions)
559    trac.setMilestoneList([])
560
561    for tracker in project.trackers:
562        # id 100 means no component selected
563        component_lookup = dict(project.get_categories(noowner=True) +
564                                [("100", None)])
565        for t in tracker.tracker_items:
566            i = trac.addTicket(type=tracker.name,
567                               time=int(t.submit_date),
568                               changetime=int(t.submit_date),
569                               component=component_lookup[t.category_id],
570                               priority=t.priority,
571                               owner=t.assignee \
572                                       if t.assignee not in user_map \
573                                       else user_map[t.assignee],
574                               reporter=t.submitter \
575                                       if t.submitter not in user_map \
576                                       else user_map[t.submitter],
577                               cc=None,
578                               # 100 means no group selected
579                               version=dict(project.groups +
580                                            [("100", None)])[t.group_id],
581                               milestone=None,
582                               status=dict(project.statuses)[t.status_id],
583                               resolution=dict(resolutions)[t.resolution_id] \
584                                       if hasattr(t, "resolution_id") else None,
585                               summary=t.summary,
586                               description=t.details,
587                               keywords='sf' + t.id)
588
589            print("Imported %s as #%d" % (t.id, i))
590
591            if len(t.attachments):
592                attmsg = "SourceForge attachments:\n"
593                for a in t.attachments:
594                    attmsg = attmsg + " * [%s %s] (%s) - added by '%s' %s [[BR]] "\
595                             % (a.url+t.id, a.filename, a.filesize+" bytes",
596                                user_map.get(a.submitter, a.submitter),
597                                time.strftime("%Y-%m-%d %H:%M:%S",
598                                              time.localtime(int(a.date))))
599                    attmsg = attmsg + "''%s ''\n" % (a.description or '')
600                    # empty description is as empty list
601                trac.addTicketComment(ticket=i,
602                                      time=time.strftime("%Y-%m-%d %H:%M:%S",
603                                              time.localtime(int(t.submit_date))),
604                                      author=None, value=attmsg)
605                print("    added information about %d attachments for #%d"
606                      % (len(t.attachments), i))
607
608            for msg in t.followups:
609                """
610                <followup>
611                <id>3280792</id>
612                <submitter>goblinhack</submitter>
613                <date>1231087739</date>
614                <details>done</details>
615                </followup>
616                """
617                trac.addTicketComment(ticket=i,
618                                      time=msg.date,
619                                      author=msg.submitter,
620                                      value=msg.details)
621            if t.followups:
622                print("    imported %d messages for #%d"
623                      % (len(t.followups), i))
624
625            # Import history
626            """
627            <history_entry>
628            <id>4452195</id>
629            <field_name>resolution_id</field_name>
630            <old_value>100</old_value>
631            <date>1176043865</date>
632            <updator>goblinhack</updator>
633            </history_entry>
634            """
635            revision = t.__dict__.copy()
636
637            # iterate the history in reverse order and update ticket revision from
638            # current (last) to initial
639            changes = 0
640            for h in sorted(t.history_entries, reverse=True):
641                """
642                 Processed fields (field - notes):
643                IP         - no target field, just skip
644                summary
645                priority
646                close_date
647                assigned_to
648
649                 Fields not processed (field: explanation):
650                File Added - TODO
651                resolution_id - need to update used_resolutions
652                status_id
653                artifact_group_id
654                category_id
655                group_id
656                """
657                f = None
658                if h.field_name in ("IP",):
659                    changes += 1
660                    continue
661                elif h.field_name in ("summary", "priority"):
662                    f = h.field_name
663                    oldvalue = h.old_value
664                    newvalue = revision.get(h.field_name, None)
665                elif h.field_name == 'assigned_to':
666                    f = "owner"
667                    newvalue = revision['assignee']
668                    if h.old_value == '100': # was not assigned
669                        revision['assignee'] = None
670                        oldvalue = None
671                    else:
672                        username = project.users[h.old_value]
673                        if username in user_map: username = user_map[username]
674                        revision['assignee'] = oldvalue = username
675                elif h.field_name == 'close_date' and revision['close_date'] != 0:
676                    f = 'status'
677                    oldvalue = 'assigned'
678                    newvalue = 'closed'
679
680                if f:
681                    changes += 1
682                    trac.addTicketChange(ticket=i,
683                                         time=h.date,
684                                         author=h.updator,
685                                         field=f,
686                                         oldvalue=oldvalue,
687                                         newvalue=newvalue)
688
689                if h.field_name != 'assigned_to':
690                    revision[h.field_name] = h.old_value
691            if changes:
692                print("    processed %d out of %d history items for #%d"
693                      % (changes, len(t.history_entries), i))
694
695
696def main():
697    import optparse
698    p = optparse.OptionParser(
699            "Usage: %prog xml_export.xml /path/to/trac/environment")
700    opt, args = p.parse_args()
701    if len(args) != 2:
702        p.error("Incorrect number of arguments")
703
704    try:
705        importData(open(args[0]), args[1], opt)
706    except DBNotEmpty as e:
707        print("Error: " + e)
708        sys.exit(1)
709
710    print(complete_msg)
711
712
713if __name__ == '__main__':
714    main()
Note: See TracBrowser for help on using the repository browser.