プロジェクト間チケットコピー機能の改造で縦割り組織のサイロ化打破?

Redmine上のプロジェクトの粒度とも関連するが、複数のプロジェクト間で情報共有しなければならないケースで、必須となるのがチケットのコピー機能だ。

ソフトウエア開発では(いや、ソフト開発に限らないかもしれないが)、顧客への「製品・サービス」や「展開品・派生品」を管理するグループ(縦軸)と、機能・コンポーネント等の単位で開発を行うグループ(横軸)で構成される、いわゆるマトリックス体制を採ることが多いと思う。多製品・サービスになればなるほど、開発部隊は開発対象をプラットフォーム化・共通化し、製品やサービスに展開することで開発効率を上げるためだ。このような組織で、プロジェクト管理ツールを使うとなると、ツール上の「プロジェクト」の粒度をよく考える必要が出てくる。

縦軸や横軸を形成するグループが複数存在し、各々が役割分担している場合、それぞれプロジェクトを分ける方が良いだろう。なぜなら、Redmineの場合、プロジェクトが分かれても、プロジェクト間でチケットのコピーを極簡単に行える(後には、Versionのプロジェクト間共有も可能となった)からだ。

そして、それだからこそ、プロジェクト間の依頼事項はチケットのコピーによって行うこととなる。チケットのコピーは、重複を生むと考えられがちだが、それはちょっと違う。グループ間でコピーされたチケットは、そもそものチケットのゴール(CLOSE条件)が違うからだ。横軸はバグ修正や機能の実装が完了すれば、そのチケットはCLOSEする。縦軸はそれらの案件を顧客にリリースすることでCLOSEすることになる。平行開発する派生品があれば、派生品毎にそれぞれCLOSEすべきタイミングがが異なることもザラだ。CLOSEするタイミングが異なる案件を一つのチケットで管理しようとする方が、かえって無理というものだ。 自分の組織では、各グループの独立性が高く、(というかグループ間の見えない壁みたいなモノで、)いわゆるサイロ化現象を起こしていた。だからこそ、一つのチケットが複数のグループで各々の責任範囲を持つとき、コピーすることで案件を分割するこの考え方が、非常にしっくりきたようだ。(またこの運用が、組織的なRedmine利用の原動力ともなった。)
もっとも、複数のグループを一つの「プロジェクト」で全て包含し、その中で「Version」(マイルストーン)を複数立てて管理することも可能であろうが、これでは各々のグループの独立性が高いほど(マトリックス開発の役割分担がはっきりしていればいるほど)、運用は困難になるだろう。

ただ、Redmineのコピーの機能で、条規の運用を行おうとしたとき、何かかゆいところに手が届かない感じがした。
  • チケット登録権限のないプロジェクトへもチケットをコピーできてしまう。
  • プロジェクト間でチケットをコピーしたことが、その時点で判らない。
  • コピーしたら、各々のチケットの進捗が判るよう、チケット関連付けをするルールにしたものの、ちょっと面倒。忘れることもしばしば。
  • 状況によっては、チケットに付随するウォッチャーもコピー先へ反映させたいことも多い。
これらを改善すべく、以下の改造を施した。


<プロジェクト間チケットコピー・移動の運用ルール>
  • チケットを取りに行くPull型がお勧め。関連するプロジェクトのチケットを参照し、他のプロジェクトで自分のプロジェクトに関するチケットが発生したら、自分のプロジェクトへコピーするという原則。プロジェクト間の最初の伝達は定例会議や口頭(生のコミュニケーションも大事)、チケットのウォッチャー指定など様々。
  • コピー・移動先のプロジェクトで、チケット登録することができ、チケットをアサインすることのできる立場の人なら、Push型でも良いが、そうでない場合無視されるチケットが出たりしかねない。(そういう意味でチケットをPushできる人を運用上決める必要性が出たり、余計なアクションが増えてしまうので、しっくりこなかった。)

<Redmineでの設定>
  • 管理-設定-チケットトラッキングで、「異なるプロジェクトのチケット間で関係の設定を許可」にチェックする。
  • 管理-ロールの設定で、Non-memberに「チケットの参照」「チケットの移動」の権限を与える。(Non-memberの場合、これをしないとコピー・移動元のチケット詳細画面を開いたり、「コピー・移動」のリンクが表示されない) ⇒この場合、自分がチケット登録の権限がないプロジェクトも、コピー・移動先プロジェクトを選択できてしまう(バグ?仕様?)ので、以下の修正を行う。

<Redmineソースの改造>(v.1.3.0ベース)
  • コピー・移動先に選択できるプロジェクトを権限に応じて絞る(チケット登録の権限のあるプロジェクト、かつ、チケット機能を使っているプロジェクト)
       # admin is allowed to move issues to any active (visible) project
       projects = Project.visible.all
     elsif User.current.logged?
-      if Role.non_member.allowed_to?(:move_issues)
-        projects = Project.visible.all
-      else
-        User.current.memberships.each {|m| projects << m.project if m.roles.detect {|r| r.allowed_to?(:move_issues)}}
-      end
+      # the projects belonging to as member.
+      User.current.memberships.each {|m| projects << m.project if m.roles.detect {|r| r.allowed_to?(:add_issues)} && m.project.module_enabled?('issue_tracking')}
     end
     projects
   end
  • コピー・移動先に指定できるプロジェクトが無い(チケット登録権限のあるプロジェクトが無い)場合にコピーできないようにする。
 <% if @copy %>

   <%= hidden_field_tag("copy_options[copy]", "1") %>
-  <%= submit_tag l(:button_copy) %>
-  <%= submit_tag l(:button_copy_and_follow), :name => 'follow' %>
+  <%= submit_tag l(:button_copy), :disabled => @allowed_projects.blank? %>
+  <%= submit_tag l(:button_copy_and_follow), :name => 'follow', :disabled => @allowed_projects.blank? %>
 <% else %>
-  <%= submit_tag l(:button_move) %>
-  <%= submit_tag l(:button_move_and_follow), :name => 'follow' %>
+  <%= submit_tag l(:button_move), :disabled => @allowed_projects.blank? %>
+  <%= submit_tag l(:button_move_and_follow), :name => 'follow', :disabled => @allowed_projects.blank? %>
 <% end %>
 <% end %>
  • コピー時に、
    ・ウォッチャーもコピーするか選択できるようにする。
    ・履歴を残す。
    ・自動的に関連付けする。
       end
     when 'attachment'
       label = l(:label_attachment)
+    when 'cp'
+      label = l(:label_issue)
+      ni = Issue.find_by_id(detail.value) and value = ni.project.to_s + " - #" + ni.id.to_s if detail.value
+      oi = Issue.find_by_id(detail.old_value) and old_value = oi.project.to_s + " - #" + oi.id.to_s if detail.old_value
     end
     call_hook(:helper_issues_show_detail_after_setting, {:detail => detail, :label => label, :value => value, :old_value => old_value })
:
:
       if detail.property == 'attachment' && !value.blank? && a = Attachment.find_by_id(detail.prop_key)
         # Link to the attachment if it has not been removed
         value = link_to_attachment(a)
+      elsif detail.property == 'cp'
+        if ni == nil
+          value = "#" + detail.value.to_s
+        else
+          value = link_to_issue(ni, :subject => false, :project => true) and value = content_tag("i", value)
+        end
+        if oi == nil
+          old_value = "#" + detail.old_value.to_s
+        else
+          old_value = link_to_issue(oi, :subject => false, :project => true) and old_value = content_tag("i", old_value)
+        end
       else
         value = content_tag("i", h(value)) if value
       end
:
:
         end
       when 'attachment'
         l(:text_journal_added, :label => label, :value => value)
+      when 'cp'
+        l(:text_journal_copied, :label => label, :old => old_value, :new => value)
       end
     else
         call_hook(:controller_issues_move_before_save, { :params => params, :issue => issue, :target_project => @target_project, :copy => !!@copy })
         if r = issue.move_to_project(@target_project, new_tracker, {:copy => @copy, :attributes => extract_changed_attributes_for_move(params)})
           moved_issues << r
+
+          if @copy
+            relation = IssueRelation.new(:issue_from => r,
+                                         :issue_to => issue,
+                                         :relation_type => IssueRelation::TYPE_RELATES)
+            if relation.save == false
+              relation.connection.rollback_db_transaction
+            end
+
+            if params[:copy_options][:copy_watchers]
+              allowed_watchers = Watcher.find(:all, :conditions => ["watchable_type like ? and watchable_id = ?", issue.class.to_s.underscore, issue.id])
+              for allowed_watcher in allowed_watchers
+                if r.project_id != issue.project_id && Issue.visible(User.find(allowed_watcher.user_id)).find_by_id(r.id) == nil
+                  next
+                end
+                new_watcher = Watcher.new(:watchable_type => "Issue",
+                                          :watchable_id => r.id,
+                                          :user_id => allowed_watcher.user_id)
+                if new_watcher.save == false
+                  new_watcher.connection.rollback_db_transaction
+                end
+              end
+            end
+
+            journal_target = r.init_journal(User.current)
+            journal_target.details << JournalDetail.new(:property => 'cp',
+                                                        :prop_key => 'issue_id',
+                                                        :old_value => issue.id,
+                                                        :value => r.id)
+            journal_allowed = issue.init_journal(User.current)
+            journal_allowed.details << JournalDetail.new(:property => 'cp',
+                                                         :prop_key => 'issue_id',
+                                                         :old_value => issue.id,
+                                                         :value => r.id)
+            if journal_target.save
+              if  journal_allowed.save == false
+                journal_target.connection.rollback_db_transaction
+                journal_allowed.connection.rollback_db_transaction
+              end
+            else
+              journal_target.connection.rollback_db_transaction
+            end
+          end
+
         else
           unsaved_issue_ids << issue.id
         end
   text_journal_set_to: "%{label} set to %{value}"
   text_journal_deleted: "%{label} deleted (%{old})"
   text_journal_added: "%{label} %{value} added"
+  text_journal_copied: "%{label} copied from %{old} to %{new}"
+  text_copy_watchers: Also copy watchers.
   text_tip_issue_begin_day: issue beginning this day
   text_tip_issue_end_day: issue ending this day
   text_tip_issue_begin_end_day: issue beginning and ending this day
   text_journal_set_to: "%{label} を %{value} にセット"
   text_journal_deleted: "%{label} を削除 (%{old})"
   text_journal_added: "%{label} %{value} を追加"
+  text_journal_copied: "%{label} を %{old} から %{new} にコピー"
+  text_copy_watchers: ウォッチャーもコピーする
   text_tip_issue_begin_day: この日に開始するタスク
   text_tip_issue_end_day: この日に終了するタスク
   text_tip_issue_begin_end_day: この日のうちに開始して終了するタスク

 <p>
   <label for='status_id'><%= l(:field_status) %></label>
+  <% if @copy %>
+    <%= select_tag('status_id', "<option value=\"#{IssueStatus.default.id}\">#{IssueStatus.default.name}</option>" + options_from_collection_for_select(@available_statuses, :id, :name)) %>
+  <% else %>
   <%= select_tag('status_id', "<option value=\"\">#{l(:label_no_change_option)}</option>" + options_from_collection_for_select(@available_statuses, :id, :name)) %>
+  <% end %>
 </p>

 <p>
:
:
 </p>
 </div>

+<% if @copy %>
+<p>
+  <%= check_box_tag "copy_options[copy_watchers]", "1" %>
+  <%= content_tag('b', l(:text_copy_watchers)) %>
+</p>
+<% end %>
+
 </fieldset>

 <fieldset><legend><%= l(:field_notes) %></legend>

0 件のコメント:

コメントを投稿