1 |
e9b44dc1
|
jenselme
|
|
2 |
|
|
|
3 |
|
|
"""This script can migrate issues from drupal to redmine.
|
4 |
|
|
|
5 |
|
|
It is design be launched with the -i option: most variables you may need for
|
6 |
|
|
debugging are easily accessible from interpreter (like HTTP status codes).
|
7 |
|
|
We make some assertion based on HTTP status code. Here are the codet you may
|
8 |
|
|
wish to know:
|
9 |
|
|
- 200: OK
|
10 |
|
|
- 201: Created
|
11 |
|
|
- 404: Not found
|
12 |
|
|
- 403: Forbidden
|
13 |
|
|
- 500: Internal error
|
14 |
|
|
Redmine gives you more intel in its response. Read them!
|
15 |
|
|
Pandoc is required to convert html syntax to textile syntax
|
16 |
8a509595
|
jenselme
|
"""
|
17 |
|
|
|
18 |
e9b44dc1
|
jenselme
|
import requests
|
19 |
565596d9
|
jenselme
|
import json
|
20 |
e9b44dc1
|
jenselme
|
import sys
|
21 |
|
|
import datetime
|
22 |
|
|
import os
|
23 |
|
|
import re
|
24 |
|
|
|
25 |
|
|
import constantes as cst
|
26 |
|
|
|
27 |
|
|
|
28 |
|
|
REGEXP_FIND_IMG = re.compile('!/.*!')
|
29 |
|
|
REGEXP_NAME_IMG = re.compile('!.*/(.*)!')
|
30 |
|
|
|
31 |
|
|
|
32 |
|
|
|
33 |
|
|
def handle_image(txt):
|
34 |
|
|
"Images are not posted automatically. There are only few of them.\
|
35 |
|
|
We just format the text with the correct textile syntax and when we post them,\
|
36 |
|
|
we will add comment and node id into a file. They should be attached to the\
|
37 |
|
|
correct issue afterwards"
|
38 |
|
|
has_image = False
|
39 |
|
|
|
40 |
|
|
images = REGEXP_FIND_IMG.findall(txt)
|
41 |
|
|
if images:
|
42 |
|
|
has_image = True
|
43 |
|
|
for image in images:
|
44 |
|
|
img_name = REGEXP_NAME_IMG.sub(r'!\1!', image)
|
45 |
f5ddb21e
|
Julien Enselme
|
txt = txt.replace(image, img_name)
|
46 |
e9b44dc1
|
jenselme
|
return txt, has_image
|
47 |
|
|
|
48 |
|
|
def html2textile(txt):
|
49 |
|
|
"Convert a txt from html to textile using pandoc"
|
50 |
|
|
|
51 |
8a509595
|
jenselme
|
txt.replace('\n', '')
|
52 |
|
|
txt.replace('\t', '')
|
53 |
e9b44dc1
|
jenselme
|
|
54 |
8a509595
|
jenselme
|
with open('tmp.html', 'w') as f:
|
55 |
|
|
f.write(txt)
|
56 |
|
|
os.system('pandoc -f html tmp.html -t textile -o tmp.textile')
|
57 |
|
|
with open('tmp.textile', 'r') as f:
|
58 |
|
|
txt = f.read()
|
59 |
e9b44dc1
|
jenselme
|
|
60 |
|
|
os.remove('tmp.html')
|
61 |
|
|
os.remove('tmp.textile')
|
62 |
|
|
return handle_image(txt)
|
63 |
|
|
|
64 |
|
|
def egalise(string, length):
|
65 |
|
|
"Make the length of string equals to length if shorter"
|
66 |
|
|
if len(string) < length:
|
67 |
|
|
return ' '*(length - len(string)) + string
|
68 |
3a9aefa9
|
jenselme
|
else:
|
69 |
e9b44dc1
|
jenselme
|
return string
|
70 |
|
|
|
71 |
|
|
def percentage(integer):
|
72 |
|
|
"Converts integer into a string used to indicate the percentage of completion\
|
73 |
|
|
of a command"
|
74 |
|
|
string = str(integer)
|
75 |
|
|
string = egalise(string, 3)
|
76 |
|
|
string = 'Completion: ' + string + '%'
|
77 |
|
|
return string + '\b'*len(string)
|
78 |
565596d9
|
jenselme
|
|
79 |
e9b44dc1
|
jenselme
|
def print_progress(str):
|
80 |
|
|
sys.stdout.write(str)
|
81 |
|
|
sys.stdout.flush()
|
82 |
565596d9
|
jenselme
|
|
83 |
e9b44dc1
|
jenselme
|
def format_date(timestamp):
|
84 |
|
|
str_timestamp = float(timestamp)
|
85 |
|
|
date = datetime.datetime.fromtimestamp(str_timestamp)
|
86 |
|
|
return date.strftime('%Y-%m-%d %H:%M:%S')
|
87 |
565596d9
|
jenselme
|
|
88 |
|
|
|
89 |
e9b44dc1
|
jenselme
|
|
90 |
|
|
|
91 |
|
|
|
92 |
|
|
|
93 |
|
|
class Comment:
|
94 |
|
|
"""Represents a drupal comment
|
95 |
|
|
"""
|
96 |
|
|
|
97 |
|
|
def __init__(self, cid, author, post_date, content, has_img):
|
98 |
|
|
self._cid = cid
|
99 |
|
|
self._author = author
|
100 |
|
|
self._post_date = post_date
|
101 |
|
|
self._content = content
|
102 |
|
|
|
103 |
|
|
self._update = {'issue': {'notes': self._content }}
|
104 |
|
|
self._update_json = json.dumps(self._update)
|
105 |
|
|
self._resp = None
|
106 |
|
|
self._has_img = has_img
|
107 |
|
|
|
108 |
|
|
def post(self, url, headers, iid, post_nb):
|
109 |
|
|
"Post the comment to url with headers (required for authentication)"
|
110 |
|
|
self._resp = requests.put(url, headers=headers, data=self._update_json)
|
111 |
|
|
assert self._resp.status_code == 200
|
112 |
|
|
|
113 |
|
|
|
114 |
|
|
with open('comments.csv', 'a', encoding='utf8') as comments_csv:
|
115 |
|
|
comments_csv.write('{},{},{}\n'.\
|
116 |
|
|
format(iid, cst.USER_ID[self._author], self._post_date))
|
117 |
|
|
with open('fix_url_comments.csv', 'a', encoding='utf8') as fix_url_csv:
|
118 |
|
|
fix_url_csv.write('{},{},{}\n'.format(self._cid, iid, post_nb))
|
119 |
|
|
|
120 |
|
|
@property
|
121 |
|
|
def post_date(self):
|
122 |
|
|
return self._post_date
|
123 |
|
|
|
124 |
|
|
@property
|
125 |
|
|
def resp(self):
|
126 |
|
|
return self._resp
|
127 |
|
|
|
128 |
|
|
@property
|
129 |
|
|
def cid(self):
|
130 |
|
|
return self._cid
|
131 |
|
|
|
132 |
|
|
@property
|
133 |
|
|
def has_img(self):
|
134 |
|
|
return self._has_img
|
135 |
|
|
|
136 |
|
|
|
137 |
|
|
|
138 |
|
|
class Updates:
|
139 |
|
|
"""Represents all the comments of a task
|
140 |
|
|
"""
|
141 |
|
|
|
142 |
|
|
def __init__(self, comments):
|
143 |
|
|
self._comments = comments
|
144 |
|
|
|
145 |
|
|
def sort(self):
|
146 |
|
|
"Sort all the updates by date of creation"
|
147 |
|
|
sorted_date = False
|
148 |
|
|
while not sorted_date:
|
149 |
|
|
sorted_date = True
|
150 |
|
|
i = 0
|
151 |
|
|
while i < len(self._comments) - 1:
|
152 |
|
|
if self._comments[i].post_date > self._comments[i + 1].post_date:
|
153 |
|
|
self._comments[i], self._comments[i + 1] = self._comments[i + 1],\
|
154 |
|
|
self._comments[i]
|
155 |
|
|
sorted_date = False
|
156 |
|
|
i += 1
|
157 |
|
|
|
158 |
|
|
def __getitem__(self, index):
|
159 |
|
|
return self._comments[index]
|
160 |
|
|
|
161 |
|
|
def __len__(self):
|
162 |
|
|
return len(self._comments)
|
163 |
|
|
|
164 |
|
|
def __iter__(self):
|
165 |
|
|
self.__i = -1
|
166 |
|
|
return self
|
167 |
|
|
|
168 |
|
|
def __next__(self):
|
169 |
|
|
self.__i += 1
|
170 |
|
|
if self.__i >= len(self._comments) or len(self._comments) == 0:
|
171 |
|
|
raise StopIteration
|
172 |
|
|
return self._comments[self.__i]
|
173 |
|
|
|
174 |
|
|
|
175 |
|
|
|
176 |
|
|
class Issue:
|
177 |
|
|
"""Represents a drupal issue
|
178 |
|
|
"""
|
179 |
|
|
|
180 |
|
|
def __init__(self, nid, comments):
|
181 |
|
|
self._nid = nid
|
182 |
|
|
self._iid = None
|
183 |
|
|
self._resp = None
|
184 |
|
|
self._comments = Updates(comments)
|
185 |
|
|
self._comments.sort()
|
186 |
|
|
self._issue = self.give_redmine_issue(nid)
|
187 |
|
|
|
188 |
|
|
def give_redmine_status_id(self, node):
|
189 |
|
|
"Translate the drupal status field to an integer representing the\
|
190 |
|
|
redmine status id"
|
191 |
|
|
drupal_status = ''
|
192 |
|
|
for elt in node['field_avancement']:
|
193 |
|
|
if "Terminée" in elt:
|
194 |
|
|
drupal_status = 'Fermée'
|
195 |
|
|
break
|
196 |
|
|
elif "Fermée" in elt:
|
197 |
|
|
drupal_status = 'Rejetée'
|
198 |
|
|
break
|
199 |
|
|
elif "pause" in elt:
|
200 |
|
|
drupal_status = 'En pause'
|
201 |
|
|
del elt
|
202 |
|
|
break
|
203 |
|
|
if not drupal_status:
|
204 |
|
|
drupal_status = 'En cours'
|
205 |
|
|
return cst.STATUS[drupal_status]
|
206 |
|
|
|
207 |
|
|
def give_redmine_issue(self, nid):
|
208 |
|
|
"Uses the nid to find the node and converts its content to something\
|
209 |
|
|
redmine can understand. Read examples for more intels"
|
210 |
|
|
node_json = requests.get(cst.BASE_URL + '/node/{}.json'.format(nid)).text
|
211 |
|
|
node = json.loads(node_json)
|
212 |
|
|
issue = dict()
|
213 |
|
|
issue['project_id'] = cst.PROJECT_ID
|
214 |
|
|
issue['tracker_id'] = cst.TRACKER_ID
|
215 |
|
|
issue['subject'] = node['title']
|
216 |
|
|
issue['description'], self._has_img = html2textile(node['body']['value'])
|
217 |
|
|
|
218 |
|
|
self._name = re.findall(cst.REGEXP_NAME, node['url'])[0]
|
219 |
|
|
|
220 |
|
|
if node['field_prioritaecute']:
|
221 |
|
|
issue['priority_id'] = cst.PRIORITY[node['field_prioritaecute']]
|
222 |
|
|
else:
|
223 |
|
|
issue['priority_id'] = cst.PRIORITY['3 - Moyenne']
|
224 |
|
|
|
225 |
|
|
if node['field_avancement']:
|
226 |
|
|
issue['done_ratio'] = cst.DONE_RATIO[node['field_avancement'][0]]
|
227 |
|
|
else:
|
228 |
|
|
issue['done_ratio'] = cst.DONE_RATIO['À commencer']
|
229 |
|
|
|
230 |
|
|
issue['status_id'] = self.give_redmine_status_id(node)
|
231 |
|
|
issue['fixed_version_id'] = cst.DRUPAL_VERSION[node['taxonomy_vocabulary_8']['id']]
|
232 |
|
|
issue['created'] = format_date(node['created'])
|
233 |
|
|
issue['author_id'] = node['author']['id']
|
234 |
|
|
|
235 |
|
|
if node['field_fichier']:
|
236 |
|
|
self._has_files = True
|
237 |
|
|
else:
|
238 |
|
|
self._has_files = False
|
239 |
|
|
return issue
|
240 |
|
|
|
241 |
|
|
def post(self, url, headers):
|
242 |
|
|
"Post the comment to url with headers (required for authentication)"
|
243 |
|
|
issue = {'issue': self._issue}
|
244 |
|
|
data = json.dumps(issue)
|
245 |
|
|
|
246 |
|
|
self._resp = requests.post(url, headers=headers, data=data)
|
247 |
|
|
|
248 |
|
|
assert self._resp.status_code == 201
|
249 |
|
|
|
250 |
|
|
resp_json = json.loads(self._resp.text)
|
251 |
|
|
self._iid = resp_json['issue']['id']
|
252 |
|
|
|
253 |
|
|
|
254 |
|
|
with open('issues.csv', 'a', encoding='utf8') as issues_csv:
|
255 |
|
|
author_id = self._issue['author_id']
|
256 |
|
|
redmine_author_id = cst.USER_ID[author_id]
|
257 |
|
|
created_on = self._issue['created']
|
258 |
|
|
issues_csv.write('{},{},{}\n'.\
|
259 |
|
|
format(self._iid, redmine_author_id, created_on))
|
260 |
|
|
with open('fix_url_issues.csv', 'a') as fix_url:
|
261 |
|
|
fix_url.write('{},{},{}\n'.format(self._nid, self._name, self._iid))
|
262 |
|
|
|
263 |
|
|
|
264 |
|
|
nb_comments = len(self._comments)
|
265 |
|
|
i = 0
|
266 |
|
|
for comment in self._comments:
|
267 |
|
|
i += 1
|
268 |
|
|
put_url = cst.URL_ISSUES + '/{}.json'.format(self._iid)
|
269 |
|
|
comment.post(put_url, headers, self._iid, i)
|
270 |
|
|
print_progress(percentage(i//nb_comments*100))
|
271 |
|
|
|
272 |
|
|
|
273 |
|
|
self.handle_image()
|
274 |
|
|
self.handle_files()
|
275 |
|
|
|
276 |
|
|
def handle_files(self):
|
277 |
|
|
if self._has_files:
|
278 |
|
|
with open('has_files.txt', 'a', encoding='utf8') as has_files_file:
|
279 |
|
|
has_files_file.write('{}\n'.format(self._nid))
|
280 |
|
|
|
281 |
|
|
def handle_image(self):
|
282 |
|
|
for comment in self._comments:
|
283 |
|
|
self._has_img = comment.has_img or self._has_img
|
284 |
|
|
if self._has_img:
|
285 |
|
|
with open('has_img.txt', 'a', encoding='utf8') as has_img_file:
|
286 |
|
|
has_img_file.write('{}\n'.format(self._nid))
|
287 |
|
|
|
288 |
|
|
@property
|
289 |
|
|
def resp(self):
|
290 |
|
|
return self._resp
|
291 |
|
|
|
292 |
|
|
@property
|
293 |
|
|
def comments_resp(self):
|
294 |
|
|
resps = dict()
|
295 |
|
|
for comment in self._comments:
|
296 |
|
|
resps[comment.cid] = comment.resp
|
297 |
|
|
return resps
|
298 |
|
|
|
299 |
|
|
|
300 |
|
|
|
301 |
|
|
class Laundry:
|
302 |
|
|
"""Contains all issues and has methods to perform the migration
|
303 |
|
|
|
304 |
|
|
Iteration of this object traverses all issues.
|
305 |
|
|
You can access any issue with the container notation
|
306 |
|
|
"""
|
307 |
|
|
|
308 |
|
|
def __init__(self, url, test=True):
|
309 |
|
|
self._issues = self.give_issues(url, test)
|
310 |
|
|
|
311 |
|
|
def give_issues(self, url, test):
|
312 |
|
|
"Returns a list of all issues"
|
313 |
|
|
issues = []
|
314 |
|
|
r = requests.get(url)
|
315 |
|
|
|
316 |
|
|
assert r.status_code == 200
|
317 |
|
|
|
318 |
|
|
|
319 |
|
|
nids = r.text.split('\r\n')[1:-1]
|
320 |
|
|
|
321 |
|
|
if test:
|
322 |
|
|
nids = nids[:3]
|
323 |
|
|
self.nb_task = len(nids)
|
324 |
|
|
i = 0
|
325 |
|
|
for nid in nids:
|
326 |
|
|
i += 1
|
327 |
|
|
comments = self.give_comments(nid)
|
328 |
|
|
print('Fetching issue no {} on {}'.format(i, self.nb_task))
|
329 |
|
|
issues.append(Issue(nid, comments))
|
330 |
|
|
return issues
|
331 |
|
|
|
332 |
|
|
def give_comments(self, nid):
|
333 |
|
|
"Returns the list of all comments of a node"
|
334 |
|
|
cids, comments_json = self.give_comments_json(nid)
|
335 |
|
|
comments = []
|
336 |
|
|
i = 0
|
337 |
|
|
nb_comments = len(comments_json)
|
338 |
|
|
for comment_json in comments_json:
|
339 |
|
|
author = comment_json['name']
|
340 |
|
|
post_date = format_date(comment_json['created'])
|
341 |
|
|
content, has_img = html2textile(comment_json['comment_body']['value'])
|
342 |
|
|
comments.append(Comment(cids[comments_json.index(comment_json)], author, post_date, content, has_img))
|
343 |
|
|
i += 1
|
344 |
|
|
print_progress(percentage(i//nb_comments*100))
|
345 |
|
|
return comments
|
346 |
|
|
|
347 |
|
|
def give_comments_json(self, nid):
|
348 |
|
|
"Get the raw json version of the drupal comment"
|
349 |
|
|
cids = self.give_comments_ids(nid)
|
350 |
|
|
comments = list()
|
351 |
|
|
for cid in cids:
|
352 |
|
|
comment = requests.get(cst.BASE_URL + '/comment/' + cid + '.json')
|
353 |
|
|
comments.append(json.loads(comment.text))
|
354 |
|
|
return cids, comments
|
355 |
|
|
|
356 |
|
|
def give_comments_ids(self, nid):
|
357 |
|
|
"Get the cid (comnments id) for a node"
|
358 |
|
|
headers = cst.Headers_GET
|
359 |
|
|
headers['X-Redmine-API-Key'] = cst.SUBMITERS[cst.MANAGER]
|
360 |
|
|
r = requests.get(cst.BASE_URL + '/entity_json/node/{}'.format(nid), headers=headers)
|
361 |
|
|
page_json = json.loads(r.text)
|
362 |
|
|
comments_json = page_json['comments']
|
363 |
|
|
|
364 |
|
|
if comments_json:
|
365 |
|
|
comments = list(comments_json.keys())
|
366 |
|
|
return comments
|
367 |
|
|
else:
|
368 |
|
|
return list()
|
369 |
|
|
|
370 |
|
|
def __iter__(self):
|
371 |
|
|
self.__i = -1
|
372 |
|
|
return self
|
373 |
|
|
|
374 |
|
|
def __next__(self):
|
375 |
|
|
self.__i += 1
|
376 |
|
|
if self.__i >= len(self._issues):
|
377 |
|
|
raise StopIteration
|
378 |
|
|
return self._issues[self.__i]
|
379 |
|
|
|
380 |
|
|
def __getitem__(self, index):
|
381 |
|
|
return self._issues[index]
|
382 |
|
|
|
383 |
|
|
def __len__(self):
|
384 |
|
|
return len(self._issues)
|
385 |
|
|
|
386 |
|
|
|
387 |
|
|
|
388 |
|
|
class Redmine:
|
389 |
|
|
"""Main class.
|
390 |
|
|
|
391 |
|
|
Allows the interaction with the program
|
392 |
|
|
You can access any issue with the container notation.
|
393 |
|
|
"""
|
394 |
|
|
def __init__(self, test=True):
|
395 |
|
|
self.reset()
|
396 |
|
|
self._test = test
|
397 |
|
|
|
398 |
|
|
def reset(self):
|
399 |
|
|
"Go to initial stage. All attributes are set to None"
|
400 |
|
|
self._laundry = None
|
401 |
|
|
self._headers = None
|
402 |
|
|
self._headers_get = None
|
403 |
|
|
|
404 |
|
|
def init(self, issues_file='issues.csv', x_redmine_api_key=cst.SUBMITERS[cst.MANAGER]):
|
405 |
|
|
"Initialize the attribute for post uses"
|
406 |
|
|
self._headers = cst.Headers
|
407 |
|
|
self._headers['X-Redmine-API-Key'] = x_redmine_api_key
|
408 |
|
|
self._laundry = Laundry(cst.LIST_TODO_CSV, self._test)
|
409 |
|
|
|
410 |
|
|
def post(self, post_url=cst.URL_ISSUES_JSON):
|
411 |
|
|
"Post all issues"
|
412 |
|
|
nb_issues = len(self._laundry)
|
413 |
|
|
i = 0
|
414 |
|
|
for issue in self._laundry:
|
415 |
|
|
i += 1
|
416 |
|
|
print('Posting issues {} on {}'.format(i, nb_issues))
|
417 |
|
|
issue.post(post_url, self._headers)
|
418 |
|
|
|
419 |
|
|
def sweep(self):
|
420 |
|
|
"Clean the redmine project of all issues."
|
421 |
|
|
print('You are about to delete all issues on your redmine project.')
|
422 |
|
|
ok = input('Do you wish to continue? (yes/no): ')
|
423 |
|
|
|
424 |
|
|
if ok == 'yes':
|
425 |
|
|
|
426 |
|
|
self._headers_get = cst.Headers_GET
|
427 |
|
|
self._headers_get['X-Redmine-API-Key'] = cst.SUBMITERS[cst.MANAGER]
|
428 |
|
|
|
429 |
|
|
pass_number = 1
|
430 |
|
|
while True:
|
431 |
|
|
r = requests.get(cst.URL_ISSUES_JSON + '?status_id=*&limit=100',\
|
432 |
|
|
headers=cst.Headers_GET)
|
433 |
|
|
|
434 |
|
|
assert r.status_code == 200
|
435 |
|
|
|
436 |
|
|
if not json.loads(r.text)['issues']:
|
437 |
|
|
break
|
438 |
|
|
print('Pass {}'.format(pass_number))
|
439 |
|
|
taches_json = json.loads(r.text)['issues']
|
440 |
|
|
|
441 |
|
|
sys.stdout.flush()
|
442 |
|
|
compt = 0
|
443 |
|
|
print_progress(percentage(compt//len(taches_json)*100))
|
444 |
|
|
for tache in taches_json:
|
445 |
|
|
tid = tache['id']
|
446 |
|
|
r = requests.delete(cst.URL_REDMINE + '/issues/{}.json'.format(tid),\
|
447 |
|
|
headers=cst.Headers_GET)
|
448 |
|
|
compt += 1
|
449 |
|
|
print_progress(percentage(int(compt/len(taches_json)*100)))
|
450 |
|
|
sys.stdout.write("\n")
|
451 |
|
|
pass_number += 1
|
452 |
|
|
else:
|
453 |
|
|
print('Wise decision')
|
454 |
|
|
|
455 |
|
|
def __getitem__(self, index):
|
456 |
|
|
if self._laundry:
|
457 |
|
|
return self._laundry[index]
|
458 |
|
|
else:
|
459 |
|
|
raise IndexError('Index out of range')
|
460 |
|
|
|
461 |
|
|
|
462 |
|
|
|
463 |
|
|
|
464 |
|
|
|
465 |
|
|
if __name__ == "__main__":
|
466 |
|
|
redmine = Redmine(test=False)
|
467 |
|
|
redmine.init()
|
468 |
|
|
redmine.post() |