Merge lp:~ceejatec/sfbugs2launchpad/user-mapping into lp:sfbugs2launchpad

Proposed by Chris Hillery
Status: Work in progress
Proposed branch: lp:~ceejatec/sfbugs2launchpad/user-mapping
Merge into: lp:sfbugs2launchpad
Diff against target: 537 lines (+374/-25)
4 files modified
README.txt (+116/-0)
convert_sf_bugs.py (+150/-25)
ng/bug-export.rnc (+97/-0)
ng/sf-user-map.rnc (+11/-0)
To merge this branch: bzr merge lp:~ceejatec/sfbugs2launchpad/user-mapping
Reviewer Review Type Date Requested Status
Gavin Panella code Pending
Launchpad code reviewers Pending
Abel Deuring Pending
Review via email: mp+78076@code.launchpad.net

Commit message

- Added support for Sourceforge->Launchpad user account mapping, to ensure
  bugs are assigned to the appropriate people after import
- Actually map assignee
- Made more detailed mapping of Sourceforge Status and Resolution fields to
  Launchpad's Status
- Map Sourceforge Group and Category fields (if set) to Launchpad Tags
- Add URL pointing to original Sourceforge bug (seems to be ignored by
  Launchpad's import process, though)
- Added full process documentation, including creation of user-mapping
  file, and also reference Relax-NG schemas

Description of the change

Added several features and upgrades (see commit message) along with full process documentation.

To post a comment you must log in.
Revision history for this message
Abel Deuring (adeuring) wrote :
Download full text (4.4 KiB)

Hi Chris,

lets me apologize that it needed ten days that somebody from the LP
engineering team took a look at your exciting work.

Aside from the new features which are very useful I appreciate very
much that you also wrote a good README file and added the Relax-NG
schema files.

I have a few comments on details of your changes (nothing serious,
see below), but my main concern is that the script is becoming
complex enough so that new features should be tested. (Well... the
entire code should be tested, but that's something for other branches ;)
If you don't have enough time to write some tests (which I would
understand quite well) I would file a bug roughly like "there is
great work waiting to be merged, once we have some tests" and
hopefully someone from the LP core team can take care of it soon.

And there is one blocker: Canonical requires that external
contributors sign a "Contributor License Agreement":
http://www.canonical.com/contributors . so, could you please sign it?

> @@ -224,14 +304,29 @@
> return date.isoformat() + 'Z'
>
>
> -def create_launchpad_issue_tree(issues, options):
> +def create_launchpad_issue_tree(issues, options, users):
> root = ET.Element("launchpad-bugs")
> root.attrib['xmlns'] = "https://launchpad.net/xmlns/2006/bugs"
>
> - def _sfuser(elem, name):
> - name = (name if name is not None else "noone").strip()
> - elem.text = name
> - elem.attrib["email"] = "%<email address hidden>" % elem.text
> + def _mapsfuser(elem, sfusername):
> + """
> + Populates the given element as a Launchpad "person", with the
> + full name as the text along with "email" and (optionally)
> + "name" attributes, where "name" is the Launchpad username. If
> + the sfusername is found in the "users" map, the values will
> + come from there; otherwise we'll make something up.
> + """
> + if (sfusername in users):

[My comment is perhaps caused by the fact that I had recently used
an object from the "surrounding function" in a local function
inadvertently...] I would prefer if users is passsed as a parameter
to _mapsfuser(). But consider this just as a suggestion ;)

> + userdata = users[sfusername]
> + elem.text = userdata.full_name
> + elem.attrib["email"] = userdata.email
> + if ("lp_user_name" in userdata):
> + elem.attrib["name"] = userdata.lp_user_name

I think lp_user_name should not appear in userdata, as such an element
is not specified in the new Relax-NG file sf-user-map.rnc. Also,
the email address provides equivalent information. So I think we don't
need the two lines above.

> + else:
> + # We have no source of information about this Sourceforge username
> + name = (sfusername if sfusername is not None else "noone").strip()
> + elem.text = name
> + elem.attrib["email"] = "%<email address hidden>" % elem.text
>
> for idx,issue in enumerate(issues):
> print "Handling issue %i/%i (%s) ..." % (idx+1, len(issues), issue.id)
> @@ -248,9 +343,15 @@
> ET.SubElement(bug, "title").text = issue.summary
> ET.SubEle...

Read more...

Revision history for this message
Gavin Panella (allenap) wrote :

There is a new version of the bug-export.rnc file now in the Launchpad
tree.

If this is still here next week when I'm on-call reviewer I'll pick it
up and get it into shape to land, including adding some tests.

Revision history for this message
Graham Binns (gmb) wrote :

Gavin, if you're happy to get this landed I'm going to mark this as work-in-progress so that it's not sticking at the top of the review queue. I'll assign the review to you.

Unmerged revisions

11. By Chris Hillery

Added instructional README.txt with full process documentation, including
creation of the user-mapping file. Also checked in the Relax-NG schemas
for the user-mapping file and the output Launchpad bug-import file.

10. By Chris Hillery

- Added support for Sourceforge->Launchpad user account mapping, to ensure
  bugs are assigned to the appropriate people after import
- Actually map assignee
- Made more detailed mapping of Sourceforge Status and Resolution fields to
  Launchpad's Status
- Map Sourceforge Group and Category fields (if set) to Launchpad Tags
- Add URL pointing to original Sourceforge bug (seems to be ignored by
  Launchpad's import process, though)

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== added file 'README.txt'
2--- README.txt 1970-01-01 00:00:00 +0000
3+++ README.txt 2011-10-04 10:43:34 +0000
4@@ -0,0 +1,116 @@
5+INSTRUCTIONS
6+------------
7+
8+To import your bugs from a Sourceforge project to Launchpad:
9+
10+1. Create a dump of your Sourceforge project by going to
11+ Project Admin > Features, and click on "XML Export" in the
12+ "Options" column for the "Backups" feature.
13+
14+2. Create a sf-user.xml mapping file (optional; see below) to
15+ map Sourceforge users to Launchpad accounts.
16+
17+3. Run:
18+
19+ convert_sf_bugs.py [-u sf-user.xml] project-dump.xml
20+
21+ (If you want all Sourceforge "Feature Requests" to be listed as
22+ "Wishlist" Importance on Launchpad, specify
23+ --wishlist-feature-requests.)
24+
25+ This will create a file named "output.xml".
26+
27+4. Verify that output.xml matches the Relax-NG schema for Launchpad
28+ bug imports, located in ng/bug-export.rnc. You can use the "rnv"
29+ tool to do this:
30+
31+ rnv ng/bug-export.rnc output.xml
32+
33+ If this reports any errors, your import will not work.
34+
35+ Be sure to check
36+
37+ https://help.launchpad.net/Bugs/ImportFormat
38+
39+ to check if there is a newer version of this schema.
40+
41+5. Host this file somewhere on the internet so Launchpad engineering
42+ can download it.
43+
44+6. Go to
45+
46+ https://answers.launchpad.net/launchpad/
47+
48+ and click "Ask a question" in the upper-right corner.
49+
50+7. Fill out the Question form, requesting an import into your project.
51+ Specify the project name on Launchpad (must already be created)
52+ as well as the URL for your XML dump from step 3.
53+
54+8. Soon (usually within 48 hours) Launchpad Engineering will perform
55+ an import to the Launchpad staging site:
56+
57+ https://staging.launchpad.net/
58+
59+ Go to your project page there and investigate the imported
60+ bugs. Note that the staging site is run on very limited hardware,
61+ so you will encounter various timeout errors and so forth. Usually
62+ reloading the page will help. Also note that the database for the
63+ staging site is updated from the production Launchpad every week or
64+ so, so your project entries will possibly look a little out of date
65+ on the staging site. Also, your imported bugs on staging will
66+ disappear in no more than a week.
67+
68+9. Assuming all is well, post a message on your Question requesting an
69+ import to the production Launchpad!
70+
71+
72+
73+SOURCEFORCE->LAUNCHPAD USER MAPPING FILE
74+----------------------------------------
75+
76+The import process run by Launchpad associates people (assignees,
77+reporters, and commenters) to a Launchpad account by the email address
78+specified in the imported XML file. By default, the email address in
79+there will be "username@users.sourceforge.net", which is almost
80+certainly not the correct email address for any Launchpad user. So by
81+default, all assignees, reporters, and commenters will be associated
82+with empty Launchpad accounts.
83+
84+To avoid this, you can create a simple XML file which maps Sourceforge
85+user names to valid email addresses for Launchpad accounts. (Obviously
86+you can only do this for people who have already created a Launchpad
87+account, so encourage all your team members to do so first and tell
88+you their registered email address.)
89+
90+The XML file is described by the Relax-NG schema located in
91+ng/sf-user-map.rnc, but a brief example should be sufficient:
92+
93+<?xml version="1.0" encoding="UTF-8"?>
94+<projectmembers>
95+ <projectmember>
96+ <full_name>Homer J. Simpson</full_name>
97+ <sf_user_name>donut_lover</sf_user_name>
98+ <email>doh@example.com</email>
99+ </projectmember>
100+ <projectmember>
101+ <full_name>Waylon Smithers</full_name>
102+ <sf_user_name>loveburns</sf_user_name>
103+ <email>yessir@example.com</email>
104+ </projectmember>
105+</projectmembers>
106+
107+Just remember that the sf_user_name element declares the Sourceforge
108+user name, but the email element must declare the email address that
109+is registered for that user on Launchpad.
110+
111+Pass this filename to the -u (or --user-mapping) argument to
112+convert_sf_bugs.py.
113+
114+Any Sourceforge users that are mentioned in your project dump that are
115+not listed in your user-mapping file will be provided with a default
116+email address as described above. That will create "phantom" accounts
117+on Launchpad. After the import is done, if those users later create a
118+Launchpad account, they may merge those phantom accounts with their
119+own by visiting the Launchpad home page of that account and clicking
120+you on the "Are you Marge Simpson?" link.
121
122=== modified file 'convert_sf_bugs.py'
123--- convert_sf_bugs.py 2010-04-13 15:09:22 +0000
124+++ convert_sf_bugs.py 2011-10-04 10:43:34 +0000
125@@ -14,6 +14,7 @@
126 import urllib2, socket
127 import base64
128 import time
129+import re
130
131 """
132 This script parses a projects XML data as exported by sourceforge
133@@ -22,18 +23,45 @@
134 """
135
136 STATUS_MAP = {
137- "Closed": "FIXRELEASED",
138 "Deleted": "WONTFIX",
139- "Open": "NEW",
140 "Pending": "INCOMPLETE",
141+ "Open": {
142+ "Accepted": "CONFIRMED",
143+ "Duplicate": "INVALID",
144+ "Fixed": "FIXCOMMITTED",
145+ "Invalid": "INVALID",
146+ "Later": "INCOMPLETE",
147+ "None": "NEW",
148+ "Out of Date": "INCOMPLETE",
149+ "Postponed": "INCOMPLETE",
150+ "Rejected": "WONTFIX",
151+ "Remind": "INCOMPLETE",
152+ "Wont Fix": "WONTFIX",
153+ "Works For Me": "INVALID"
154+ },
155+ "Closed": {
156+ "Accepted": "FIXRELEASED",
157+ "Duplicate": "INVALID",
158+ "Fixed": "FIXRELEASED",
159+ "Invalid": "INVALID",
160+ "Later": "WONTFIX",
161+ "None": "UNKNOWN",
162+ "Out of Date": "WONTFIX",
163+ "Postponed": "WONTFIX",
164+ "Rejected": "WONTFIX",
165+ "Remind": "WONTFIX",
166+ "Wont Fix": "WONTFIX",
167+ "Works For Me": "INVALID",
168+ },
169 }
170
171+
172 IMPORTANCE_MAP = {
173 "9": "CRITICAL",
174 "8": "HIGH",
175 "7": "HIGH",
176 "6": "MEDIUM",
177- "5": "UNDECIDED",
178+ "5": "MEDIUM",
179 "4": "MEDIUM",
180 "3": "LOW",
181 "2": "LOW",
182@@ -85,6 +113,41 @@
183 d[n.find(idname).text] = n.find(valuename).text
184 self[name] = d
185
186+def parse_users(sf_user_nodes, usermap):
187+ """
188+ Parse all sf.net users and return a dictionary. Keys are sf user
189+ names. Values are dictionaries with "email", "sf_user_name", and
190+ "full_name".
191+ """
192+ users = {}
193+
194+ # First populate from usermap (if provided)
195+ count = 0
196+ if (usermap):
197+ for user in usermap:
198+ user_dict = NodeDict()
199+ user_dict.from_node("email", user)
200+ user_dict.from_node("full_name", user)
201+ user_dict.from_node("sf_user_name", user)
202+
203+ users[user_dict.sf_user_name] = user_dict
204+ count += 1
205+ print "Read %d users from user-map file" % count
206+
207+ # Now extend with any SF users that aren't in the usermap
208+ count = 0
209+ for sf_user in sf_user_nodes:
210+ sf_user_dict = NodeDict()
211+ sf_user_dict.from_node("sf_user_name", sf_user, "user_name")
212+ if (sf_user_dict.sf_user_name not in users):
213+ sf_user_dict.from_node("email", sf_user)
214+ sf_user_dict.from_node("full_name", sf_user, "public_name")
215+ users[sf_user_dict.sf_user_name] = sf_user_dict
216+ count += 1
217+ print "Read %d users from Sourceforge project dump" % count
218+
219+ return users
220+
221 def parse_tracker_items(item_nodes, tracker):
222 parsed_items = []
223
224@@ -118,6 +181,10 @@
225 "group", item, "group_id", tracker.groups.get)
226 item_dict.try_from_node(
227 "resolution", item, "resolution_id", tracker.resolutions.get)
228+ # Ensure all tracker items have a resolution, even if the tracker
229+ # doesn't have the "Resolution" field
230+ if ("resolution" not in item_dict):
231+ item_dict["resolution"] = "None"
232
233 # Parse follow ups
234 item_dict["followups"] = []
235@@ -183,9 +250,22 @@
236 tracker.parse_lookup_dict(
237 tracker_node, "statuses", "status", "id", "name")
238
239+ # "100" is the default (unset) value for category_id and group_id
240 tracker.categories["100"] = "None"
241 tracker.groups["100"] = "None"
242
243+ # Transform all category names and group names into tag-friendly form
244+ def _tagify(adict):
245+ for category in adict:
246+ catname = adict[category]
247+ # Special case
248+ catname = catname.replace('C++', 'cplusplus')
249+ catname = re.sub(r'[^A-Za-z0-9.-]+', '-', catname.lower())
250+ catname = catname.strip('-')
251+ adict[category] = catname
252+ _tagify(tracker.categories)
253+ _tagify(tracker.groups)
254+
255 issues.extend(
256 parse_tracker_items(tracker_node.find("tracker_items"), tracker))
257 return issues
258@@ -224,14 +304,29 @@
259 return date.isoformat() + 'Z'
260
261
262-def create_launchpad_issue_tree(issues, options):
263+def create_launchpad_issue_tree(issues, options, users):
264 root = ET.Element("launchpad-bugs")
265 root.attrib['xmlns'] = "https://launchpad.net/xmlns/2006/bugs"
266
267- def _sfuser(elem, name):
268- name = (name if name is not None else "noone").strip()
269- elem.text = name
270- elem.attrib["email"] = "%s@users.sf.net" % elem.text
271+ def _mapsfuser(elem, sfusername):
272+ """
273+ Populates the given element as a Launchpad "person", with the
274+ full name as the text along with "email" and (optionally)
275+ "name" attributes, where "name" is the Launchpad username. If
276+ the sfusername is found in the "users" map, the values will
277+ come from there; otherwise we'll make something up.
278+ """
279+ if (sfusername in users):
280+ userdata = users[sfusername]
281+ elem.text = userdata.full_name
282+ elem.attrib["email"] = userdata.email
283+ if ("lp_user_name" in userdata):
284+ elem.attrib["name"] = userdata.lp_user_name
285+ else:
286+ # We have no source of information about this Sourceforge username
287+ name = (sfusername if sfusername is not None else "noone").strip()
288+ elem.text = name
289+ elem.attrib["email"] = "%s@users.sf.net" % elem.text
290
291 for idx,issue in enumerate(issues):
292 print "Handling issue %i/%i (%s) ..." % (idx+1, len(issues), issue.id)
293@@ -248,9 +343,15 @@
294 ET.SubElement(bug, "title").text = issue.summary
295 ET.SubElement(bug, "description").text = issue.details
296
297- _sfuser(ET.SubElement(bug, "reporter"), issue.submitter)
298+ _mapsfuser(ET.SubElement(bug, "reporter"), issue.submitter)
299+ _mapsfuser(ET.SubElement(bug, "assignee"), issue.assignee)
300
301- ET.SubElement(bug, "status").text = STATUS_MAP[issue.status]
302+ c = ET.SubElement(bug, "status")
303+ status = STATUS_MAP[issue.status]
304+ try:
305+ c.text = status[issue.resolution]
306+ except TypeError:
307+ c.text = status
308
309 if (options.wishlist_feature_requests and
310 issue.node_type == NodeTypes.FEATURE_REQUEST):
311@@ -261,24 +362,33 @@
312 IMPORTANCE_MAP[issue.priority])
313
314 # TODO: Not handled: milestone
315- # TODO: Not handled: assignee
316- # TODO: Not handled: urls
317 # TODO: Not handled: cves
318 # TODO: Not handled: bugwatches
319- # TODO: Not handled: assignee
320-
321- # REALLYTODO: Not handled: tags
322+
323+ # Add a URL pointing to the original Sourceforge bug
324+ c = ET.SubElement(bug, "urls")
325+ c = ET.SubElement(c, "url")
326+ c.text = "Sourceforge bug #%s" % issue.id
327+ c.attrib["href"] = issue.url
328+
329+ # Add tags based on the group and category
330+ c = ET.SubElement(bug, "tags")
331+ def _addtag(name):
332+ if (name != "none"):
333+ ET.SubElement(c, "tag").text = name
334+ _addtag(issue.category)
335+ _addtag(issue.group)
336
337 # Add the obligatory first comment
338 c = ET.SubElement(bug, "comment")
339- _sfuser(ET.SubElement(c, "sender"), issue.submitter)
340+ _mapsfuser(ET.SubElement(c, "sender"), issue.submitter)
341 ET.SubElement(c, "date").text = format_date(issue.submit_date)
342 ET.SubElement(c, "title").text = issue.summary
343 ET.SubElement(c, "text").text = issue.details
344
345 for follow in issue.followups:
346 c = ET.SubElement(bug, "comment")
347- _sfuser(ET.SubElement(c, "sender"), follow.submitter)
348+ _mapsfuser(ET.SubElement(c, "sender"), follow.submitter)
349 ET.SubElement(c, "date").text = format_date(follow.date)
350 ET.SubElement(c, "title").text = "RE: " + issue.summary
351 ET.SubElement(c, "text").text = follow.details
352@@ -286,12 +396,12 @@
353 # Add attached files
354 for aidx, attach in enumerate(issue.attachments):
355 c = ET.SubElement(bug, "comment")
356- _sfuser(ET.SubElement(c, "sender"), attach.submitter)
357+ _mapsfuser(ET.SubElement(c, "sender"), attach.submitter)
358 ET.SubElement(c, "date").text = format_date(attach.date)
359 ET.SubElement(c, "title").text = "RE: " + issue.summary
360 ET.SubElement(c, "text").text = "The file %s was added: %s" % (
361 attach.filename, attach.description)
362- print " Downloading Attachement %i/%i..." % (
363+ print " Downloading Attachment %i/%i..." % (
364 aidx+1,len(issue.attachments)),
365 data = download_url(attach.url + issue.id)
366 print("%i bytes" % len(data))
367@@ -308,8 +418,7 @@
368 ET.SubElement(at, "mimetype").text = attach.filetype
369 ET.SubElement(at, "contents").text = data
370
371-
372- # indent(root)
373+ indent(root)
374 et = ET.ElementTree(root)
375
376 return et
377@@ -317,21 +426,37 @@
378
379 def main():
380 option_parser = optparse.OptionParser(
381- "%prog <sf.net xml project export file>")
382+ "%prog [options] <sf.net xml project export file>")
383
384 option_parser.add_option(
385 '--wishlist-feature-requests', action='store_true', default=False,
386 help='give imported feature requests the priority "WISHLIST"')
387
388+ option_parser.add_option(
389+ '--user-mapping', '-u', action='store', type='string',
390+ dest='user_map_file',
391+ help="provide mapping from sf.net usernames to Launchpad user info")
392+
393 options, args = option_parser.parse_args()
394 if len(args) < 1:
395- option_parser.print_usage()
396+ option_parser.print_help()
397 return -1
398
399- issues = parse_issues(ET.parse(args[0]).getroot().find("trackers"))
400+ # Read in entire sf.net project file
401+ sfdump = ET.parse(args[0]).getroot()
402+
403+ # Read in user-mapping file, if specified
404+ usermap = None
405+ if (options.user_map_file):
406+ usermap = ET.parse(options.user_map_file).getroot()
407+
408+ # Create user lookup table from project dump and mapping file
409+ users = parse_users(sfdump.find("projectsummary/projectmembers"), usermap)
410+
411+ issues = parse_issues(sfdump.find("trackers"))
412 print "Parsed %i issues..." % len(issues)
413
414- et = create_launchpad_issue_tree(issues, options)
415+ et = create_launchpad_issue_tree(issues, options, users)
416
417 et.write("output.xml", encoding="utf-8")
418
419
420=== added directory 'ng'
421=== added file 'ng/bug-export.rnc'
422--- ng/bug-export.rnc 1970-01-01 00:00:00 +0000
423+++ ng/bug-export.rnc 2011-10-04 10:43:34 +0000
424@@ -0,0 +1,97 @@
425+default namespace = "https://launchpad.net/xmlns/2006/bugs"
426+
427+start = lpbugs
428+
429+# Data types
430+
431+boolean = "True" | "False"
432+lpname = xsd:string { pattern = "[a-z0-9][a-z0-9\+\.\-]*" }
433+cvename = xsd:string { pattern = "(19|20)[0-9][0-9]-[0-9][0-9][0-9][0-9]" }
434+
435+# XXX: jamesh 2006-04-11 bug=105401:
436+# These status and importance values need to be kept in sync with the
437+# rest of Launchpad. However, there are not yet any tests for this.
438+# https://bugs.launchpad.net/bugs/105401
439+status = (
440+ "NEW" |
441+ "INCOMPLETE" |
442+ "INVALID" |
443+ "WONTFIX" |
444+ "CONFIRMED" |
445+ "TRIAGED" |
446+ "INPROGRESS" |
447+ "FIXCOMMITTED" |
448+ "FIXRELEASED" |
449+ "UNKNOWN")
450+importance = (
451+ "UNKNOWN" |
452+ "CRITICAL" |
453+ "HIGH" |
454+ "MEDIUM" |
455+ "LOW" |
456+ "WISHLIST" |
457+ "UNDECIDED")
458+
459+# Content model for a person element. The element content is the
460+# person's name. For successful bug import, an email address must be
461+# provided.
462+person = (
463+ attribute name { lpname }?,
464+ attribute email { text }?,
465+ text)
466+
467+lpbugs = element launchpad-bugs { bug* }
468+
469+bug = element bug {
470+ attribute id { xsd:integer } &
471+ element private { boolean }? &
472+ element security_related { boolean }? &
473+ element duplicateof { xsd:integer }? &
474+ element datecreated { xsd:dateTime } &
475+ element nickname { lpname }? &
476+ # The following will likely be renamed summary in a future version.
477+ element title { text } &
478+ element description { text } &
479+ element reporter { person } &
480+ element status { status } &
481+ element importance { importance } &
482+ element milestone { lpname }? &
483+ element assignee { person }? &
484+ element urls {
485+ element url { attribute href { xsd:anyURI }, text }*
486+ }? &
487+ element cves {
488+ element cve { cvename }*
489+ }? &
490+ element tags {
491+ element tag { lpname }*
492+ }? &
493+ element bugwatches {
494+ element bugwatch { attribute href { xsd:anyURI } }*
495+ }? &
496+ element subscriptions {
497+ element subscriber { person }*
498+ }? &
499+ comment+
500+}
501+
502+# A bug has one or more comments. The first comment duplicates the
503+# reporter, datecreated, title, description of the bug.
504+comment = element comment {
505+ element sender { person } &
506+ element date { xsd:dateTime } &
507+ element title { text }? &
508+ element text { text } &
509+ attachment*
510+}
511+
512+# A bug attachment. Attachments are associated with a bug comment.
513+attachment = element attachment {
514+ attribute href { xsd:anyURI }? &
515+ element type { "PATCH" | "UNSPECIFIED" }? &
516+ element filename { text }? &
517+ # The following will likely be renamed summary in a future version.
518+ element title { text }? &
519+ element mimetype { text }? &
520+ element contents { xsd:base64Binary }
521+}
522
523=== added file 'ng/sf-user-map.rnc'
524--- ng/sf-user-map.rnc 1970-01-01 00:00:00 +0000
525+++ ng/sf-user-map.rnc 2011-10-04 10:43:34 +0000
526@@ -0,0 +1,11 @@
527+default namespace = ""
528+
529+start = projectmembers
530+
531+projectmembers = element projectmembers { projectmember* }
532+
533+projectmember = element projectmember {
534+ element full_name { text } &
535+ element sf_user_name { text } &
536+ element email { text }
537+}

Subscribers

People subscribed via source and target branches