Project

General

Profile

p2r.rb

Alexei Siventsev, 2019-05-23 12:42

 
1
# Copyright (c) 2019 NPO Karat
2
# Author: Aleksei Siventsev
3
#=====================================================================
4
# Console Script for MS Project to Redmine synchronization
5
#=====================================================================
6
VER = '0.3 22/05/19'
7
HDR = "Console Script for MS Project to Redmine synchronization v#{VER} (c) A. Siventsev 2019"
8

    
9
require 'yaml'
10
require 'win32ole'
11
require 'net/http'
12
require 'json'
13
require 'date'
14
require './p2r_lib.rb'
15

    
16
puts '', HDR, ('=' * HDR.scan(/./mu).size), ''
17

    
18
#---------------------------------------------------------------------
19
# process command line arguments
20
#---------------------------------------------------------------------
21
# answer help request and exit
22
chk !(ARGV & %w(h H -h -H /h /H ? -? /? help -help --help)).empty?, HELP
23
# check execution request
24
DRY_RUN = (ARGV & %w(e E -e -E /e /E exec -exec --exec execute -execute --execute)).empty?
25
puts "DRY RUN (add -e key for actual execution)\n\n" if DRY_RUN
26

    
27
#---------------------------------------------------------------------
28
# connect to .msp
29
#---------------------------------------------------------------------
30
msg = 'Please open your MS Project file and leave it active with no dialogs open'
31
begin
32
  pserver = WIN32OLE.connect 'MSProject.Application'
33
rescue
34
  chk true, msg
35
end
36
$msp = pserver.ActiveProject
37
chk !$msp,msg
38
$msp_name = $msp.Name.clone.encode 'UTF-8'
39

    
40
#---------------------------------------------------------------------
41
# find and process settings task
42
#---------------------------------------------------------------------
43
settings_task = nil
44
(1..$msp.Tasks.Count).each do |i|
45
  raw = $msp.Tasks(i)
46
  if raw && raw.Name == 'Redmine Synchronization'
47
    settings_task = raw
48
    break
49
  end
50
end
51
chk !settings_task, 'ERROR: task with name \'Redmine Sysncronization\' was not found in the project.'
52

    
53
begin
54
  $settings = YAML.load settings_task.Notes.to_s.gsub("\r", "\n")
55
rescue
56
  chk true, 'ERROR: could not extract settiings from Notes in \'Redmine Sysncronization\' task (YAML format expected)'
57
end
58

    
59
rmp_id = $settings.delete 'redmine_project_id'
60
missed_pars = %w(redmine_host redmine_api_key redmine_project_uuid resource_default_redmine_role_id) - $settings.keys
61

    
62
chk !missed_pars.empty?, "ERROR: following settings not found in 'Redmine Sysncronization' task: #{missed_pars.sort.join ', '}"
63

    
64
#---------------------------------------------------------------------
65
# check Redmine project availability
66
#---------------------------------------------------------------------
67
# 401 ERROR: not authorized bad key
68
# 404 not found
69
#   if rpr_id then ERROR: suppose project has been published already
70
#   else project is to be published
71
# 403 forbidden
72
# 200 ок
73
#   if rpr_id then
74
#     if prp_id == project id then OK to proceed
75
#     else ERROR: different ids in project and redmine
76
#   else ERROR: suppose project is to be published but found it is already published
77
#
78

    
79
$uuid = $settings['redmine_project_uuid']
80
project_path="/projects/#{$uuid}.json"
81
re = rm_request(project_path)
82

    
83
case re.code
84
  when '401'
85
    chk true, 'ERROR: not authorized by Redmine (maybe bad api key?)'
86
  when '404'
87
    if rmp_id # else proceed
88
      chk true, "ERROR: suppose project '#{$uuid}' has been published already (because redmine_project_id is provided) but have not found it"
89
    end
90
  when '403'
91
    chk true, "ERROR: access to project '#{$uuid}' in Redmine is forbidden, ask Redmine admin"
92
  when '200'
93
    begin
94
      rmp = JSON.parse(re.body)
95
    rescue
96
      chk true, "ERROR: wrong reply format to '/projects/#{$uuid}.json' (JSON expected)"
97
    end
98
    rmp = rmp['project']
99
    chk !rmp, "ERROR: wrong reply format to '/projects/#{$uuid}.json' ('project' key not found)"
100
    if rmp_id
101
      unless rmp_id == rmp['id'] # else proceed
102
        chk true, "ERROR: Redmine project id does not comply with redmine_project_id provided in settings"
103
      end
104
    else
105
      chk true, "ERROR: suppose have to create new project '#{$uuid}' (because redmine_project_id is not provided) but found the project with that uuid has been published already"
106
    end
107
  else
108
    chk true, "ERROR: #{re.code} #{re.message}"
109
end
110

    
111
#---------------------------------------------------------------------
112
# check default tracker and role
113
#---------------------------------------------------------------------
114

    
115
if ($dflt_tracker_id = $settings['task_default_redmine_tracker_id'])
116
  chk !$dflt_tracker_id.is_a?(Integer), "ERROR: parameter task_default_redmine_tracker_id must be integer"
117
  trackers = rm_get '/trackers.json', 'trackers', 'ERROR: could not get Redmine tracker list'
118
  found = false
119
  trackers.each do |t|
120
    if t['id'] = $dflt_tracker_id
121
      found=true
122
      break
123
    end
124
  end
125
  chk !found, "ERROR: tracker not found for parameter task_default_redmine_tracker_id = #{$dflt_tracker_id}"
126
end
127

    
128
$dflt_role_id = $settings['resource_default_redmine_role_id']
129
chk !$dflt_tracker_id.is_a?(Integer), "ERROR: parameter task_default_redmine_tracker_id must be integer"
130
rm_get "/roles/#{$dflt_role_id}.json", 'role', "ERROR: could not get default team role resource_default_redmine_role_id=#{$dflt_role_id}"
131

    
132
#---------------------------------------------------------------------
133
# util for loading project team
134
#---------------------------------------------------------------------
135
$team = {}
136
def load_team
137
  offset = 0
138
  loop do
139
    re = rm_request "/projects/#{$uuid}/memberships.json?offset=#{offset}"
140
    chk (re.code != '200'), 'ERROR: could not get team list of Redmine project'
141
    re = JSON.parse(re.body) rescue nil
142
    chk re.nil?, 'ERROR: could not parse reply of team list request'
143
    break if re['memberships'].empty? or re['limit'].nil?
144
    re['memberships'].each do |m|
145
      $team[m['user']['id']] = m
146
    end
147
    offset += re['limit']
148
  end
149
end
150

    
151
#---------------------------------------------------------------------
152
# some utils for msp custom fields editing
153
#---------------------------------------------------------------------
154

    
155
def set_mst_url(mst, rmt_id)
156
  url="http://#{$settings['redmine_host']}:#{$settings['redmine_port']}/issues/#{rmt_id}"
157
  mst.HyperlinkAddress = url
158
  return url
159
end
160

    
161
#---------------------------------------------------------------------
162
# task (issue) processing util
163
#---------------------------------------------------------------------
164

    
165
$rmts={} # issues processed
166
$rmus=[] # memberships processed
167

    
168
def process_issue rmp_id, mst, force_new_task = false, is_group = false
169

    
170
  mst_name = mst.Name.clone.encode 'UTF-8'
171
  rmt_id = mst.Hyperlink
172
  return nil unless rmt_id =~ /^\s*\d+\s*$/ # task not marked for sync
173
  rmt_id = rmt_id.to_i
174

    
175
  if (rmt = $rmts[rmt_id])
176
    return rmt # already processed
177
  end
178

    
179
  # process task parent:
180
  mst_papa = mst.OutlineParent
181
  rmt_papa = nil
182
  unless mst_papa.UniqueID == 0 # suppose project summary task has UniqueID = 0
183
    rmt_papa_id = mst_papa.Hyperlink
184
    if rmt_papa_id =~ /^\s*\d+\s*$/
185
      rmt_papa_id = rmt_papa_id.to_i
186
      rmt_papa = $rmts[rmt_papa_id]
187
      unless rmt_papa
188
        rmt_papa = process_issue rmp_id, mst_papa, force_new_task, true
189
      end
190
    else
191
      rmt_papa_id = 0
192
      mst_papa.Hyperlink = '0'
193
      rmt_papa = process_issue rmp_id, mst_papa, false, true
194
    end
195

    
196
  end
197

    
198
  # check task resource appointment
199
  #   we expect not more than one synchronizable appointment
200
  rmu_id_ok = nil
201
  msr_ok = nil
202
  (1..mst.Resources.Count).each do |j|
203
    next unless msr = mst.Resources(j)
204
    rmu_id = msr.Hyperlink
205
    next unless rmu_id =~ /^\s*\d+\s*$/ # resource not marked for sync
206
    chk rmu_id_ok, "ERROR: more than one sync resource for MSP task #{mst.ID} '#{mst_name}'"
207
    rmu_id = rmu_id.to_i
208

    
209
    if $rmus.include? rmu_id
210
      # resource already processed
211
      rmu_id_ok = rmu_id
212
      msr_ok = msr
213
    else
214
      member = $team[rmu_id]
215
      unless member
216
        # Redmine user is not team member - create new membership
217
        # check user availability
218
        re = rm_request "/users/#{rmu_id}.json"
219
        chk (re.code != '200'), "ERROR: Redmine user #{rmu_id} not found for resource in MSP task #{mst.ID} '#{mst_name}'"
220
        re = JSON.parse(re.body) rescue nil
221
        chk re.nil?, "ERROR: could not parse reply: Redmine user #{rmu_id} not found for resource in MSP task #{mst.ID} '#{mst_name}'"
222
        # create membership
223
        data = {user_id: rmu_id, role_ids: [$dflt_role_id]}
224
        member = rm_create "/projects/#{$uuid}/memberships.json", 'membership', data,
225
                         "ERROR: could not create Redmine project membership for user #{rmu_id}"
226
        puts "New membership created for user: #{rmu_id}"
227
        $team[rmu_id] = member
228
      end
229
      rmu_id_ok = rmu_id
230
      msr_ok = msr
231
    end
232
  end
233
  if rmu_id_ok
234
    rmu_name = $team[rmu_id_ok]['user']['name']
235
    msr_name = msr_ok.Name.clone.encode 'UTF-8'
236
    unless rmu_name == msr_name
237
      puts "WARNING: RM user ID=#{rmu_id_ok} name '#{rmu_name}' does not correspond to MSP resource name '#{msr_name}' (task ##{rmt_id} for #{mst.ID} '#{mst_name}')"
238
    end
239
  end
240

    
241
  if rmt_id == 0 || force_new_task
242

    
243
    # create new task
244
    unless DRY_RUN
245
      rmt = {
246
          project_id: rmp_id, subject: mst_name, description: "-----\nAutocreated by P2R from MSP task #{mst.ID} in MSP project #{$msp_name}\n-----\n",
247
          start_date: mst.Start.strftime('%Y-%m-%d'), due_date: mst.Finish.strftime('%Y-%m-%d'),
248
          assigned_to_id: rmu_id_ok, tracker_id: $dflt_tracker_id,
249
          parent_issue_id: (rmt_papa ? rmt_papa['id'] : '')
250
      }
251
      rmt['estimated_hours'] = mst.Work/60 unless is_group
252
      rmt = rm_create '/issues.json', 'issue', rmt,
253
                      "ERROR: could not create Redmine task from #{mst.ID} '#{mst_name}' for some reasons"
254
      # write new task number to MSP
255
      mst.Hyperlink = rmt['id']
256
      set_mst_url mst, rmt['id']
257
      puts "Created task Redmine ##{rmt['id']} from MSP #{mst.ID} '#{mst_name}'"
258

    
259
      $rmts[rmt['id']] = rmt
260
      return rmt
261

    
262
    else
263
      # keep task to be created
264
      puts "Will create task #{mst.ID} '#{mst_name}'"
265

    
266
      return nil
267

    
268
    end
269

    
270
  else
271

    
272
    # update existing task
273
    #   check task availability
274
    rmt = rm_get "/issues/#{rmt_id}.json", 'issue', "ERROR: could not find Redmine task ##{rmt_id} for #{mst.ID} '#{mst_name}'"
275

    
276
    #   check for changes
277
    #     to RM: subject - Name, parent_id - OutlineParent.Hyperlink, assigned_to_id - rmu_id_ok
278
    #     to MSP: start_date - Start, due_date - Finish, estimated_hours - Work, sum of reports - ActualWork
279

    
280
    # collect changes to RM
281
    changes={}
282
    changes['assigned_to_id'] = (rmu_id_ok || '') if rmu_id_ok != (rmt['assigned_to'] ? rmt['assigned_to']['id'] : nil)
283
    changes['subject'] = mst_name if rmt['subject'] != mst_name
284
    rmt_papa_id_old = (rmt['parent'] ? rmt['parent']['id'] : '')
285
    rmt_papa_id_new = (rmt_papa ? rmt_papa['id'] : '')
286
    changes['parent_issue_id'] = rmt_papa_id_new if rmt_papa_id_new != rmt_papa_id_old
287

    
288
    # collect changes to MSP
289
    unless is_group
290
      changes2={}
291
      d = mst.Start.strftime('%Y-%m-%d')
292
      changes2['start_date'] = rmt['start_date'] if rmt['start_date'] != d
293
      d = mst.Finish.strftime('%Y-%m-%d')
294
      changes2['due_date'] = rmt['due_date'] if rmt['due_date'] != d
295
      # calculate estimate and spent hours
296
      spent = (rmt['spent_hours'] || 0.0) * 60
297
      changes2['spent_hours'] = spent if spent != mst.ActualWork
298
      est = (rmt['estimated_hours'] || 0.0) * 60
299
      if rmt['done_ratio'] > 0 && spent > 0
300
        # we will consider priority of done ratio over estimated hours
301
        est = spent * 100 / rmt['done_ratio']
302
      elsif rmt['done_ratio'] == 0 && spent > 0
303
        # done ratio is wrong
304
        if est >= spent
305
          # some discrepancy - we will fix done ratio in RM
306
          changes['done_ratio'] = spent * 100 / est
307
        else
308
          # some error in estimate? we will ignore estimate and warn
309
          est = nil
310
          puts "Warning: estimated hours less than spent hours, estimate will be ignored"
311
        end
312
      end
313
      changes2['estimated_hours'] = est if est && est != mst.Work
314
    end
315

    
316
    # apply changes to RM
317
    if changes.empty?
318
      puts "No changes for Task Redmine ##{rmt_id} from MSP #{mst.ID} '#{mst_name}'"
319
    else
320
      # apply changes
321
      changelist = changes.keys.join(', ')
322
      changes['notes'] = "Autoupdated by P2P at #{Time.now.strftime '%Y-%m-%d %H:%M'} (#{changelist})"
323
      if DRY_RUN
324
        puts "Will update task Redmine ##{rmt_id} from MSP #{mst.ID} '#{mst_name}' (#{changelist})"
325
      else
326
        rm_update "/issues/#{rmt['id']}.json",  {issue: changes},
327
                  "ERROR: could not update Redmine task ##{rmt['id']} from #{mst.ID} '#{mst_name}' for some reasons"
328
        rmt = rm_get "/issues/#{rmt_id}.json", 'issue', "ERROR: could not find Redmine task ##{rmt_id} for #{mst.ID} '#{mst_name}'"
329
        puts "Updated task Redmine ##{rmt_id} from MSP #{mst.ID} '#{mst_name}' (#{changelist})"
330
      end
331
    end
332

    
333
    # apply changes to MSP
334
    unless is_group
335
      if changes2.empty?
336
        puts "No changes for MSP task #{mst.ID} '#{mst_name}'"
337
      else
338
        # apply changes
339
        changelist2 = changes2.keys.join(', ')
340
        if DRY_RUN
341
          puts "Will update MSP task #{mst.ID} '#{mst_name}'  from Redmine ##{rmt_id} (#{changelist2})"
342
        else
343
          changes2.each do |k,v|
344
            case k
345
              when 'start_date'; mst.Start = Time.new *(v.split /\D+/ )
346
              when 'due_date';        mst.Finish = Time.new *(v.split /\D+/ )
347
              when 'spent_hours';     mst.ActualWork = v
348
              when 'estimated_hours'; mst.Work = v
349
            end
350
          end
351
          puts "Updated MSP task #{mst.ID} '#{mst_name}' from Redmine ##{rmt_id} (#{changelist2})"
352
        end
353
      end
354
    end
355
    set_mst_url mst, rmt['id']
356

    
357
    $rmts[rmt['id']] = rmt
358
    return rmt
359

    
360
  end
361

    
362
end
363

    
364
#---------------------------------------------------------------------
365
# iterate over task list
366
#---------------------------------------------------------------------
367

    
368
def process_issues rmp_id, force_new_task = false
369

    
370
  (1..$msp.Tasks.Count).each do |i|
371

    
372
    # check msp task
373
    next unless mst = $msp.Tasks(i)
374

    
375
    is_group = (mst.OutlineChildren.Count > 0)
376
    process_issue rmp_id, mst, force_new_task, is_group
377

    
378
  end
379
end
380

    
381
#=====================================================================
382
# main work cycle
383
#=====================================================================
384

    
385
settings_task.Start = Time.now unless DRY_RUN
386

    
387
if rmp_id
388
  #=====================================================================
389
  # existing Redmine project update
390
  #---------------------------------------------------------------------
391

    
392
  puts 'Existing Redmine project update'
393

    
394
else
395
  #=====================================================================
396
  # new Redmine project creation
397
  #---------------------------------------------------------------------
398
  if DRY_RUN
399
    # project creation requested - exit on dry run
400
    chk true, "Will create new Redmine project #{$uuid} from MSP project #{$msp_name}"
401
  end
402

    
403
  #---------------------------------------------------------------------
404
  # new Redmine project create
405
  #---------------------------------------------------------------------
406
  rmp = {name: $msp_name, identifier: $uuid, is_public: false}
407
  rmp = rm_create '/projects.json', 'project', rmp,
408
      'ERROR: could not create Redmine project for some reasons'
409

    
410
  # add rm project id to msp settings
411
  $settings['redmine_project_id'] = rmp['id']
412
  settings_task.Notes = YAML.dump $settings
413
  puts "Created new Redmine project #{$uuid} ##{rmp['id']} from MSP project #{$msp_name}"
414

    
415
  #---------------------------------------------------------------------
416
  # add tasks to Redmine project
417
  #---------------------------------------------------------------------
418

    
419
end
420

    
421
load_team
422
process_issues (rmp_id || rmp['id']), rmp_id.nil?
423

    
424
settings_task.Finish = Time.now unless DRY_RUN
425

    
426
puts "\n\n"
427

    
428

    
(2-2/3)