From db19e72027f21e573eb7889791e37ffa14a9a925 Mon Sep 17 00:00:00 2001
From: Annabel Dunstone <annabel.dunstone@gmail.com>
Date: Fri, 22 Jul 2016 17:28:39 -0700
Subject: [PATCH] Add route, controller action, and views for MR pipelines

---
 .../javascripts/merge_request_tabs.js.coffee  | 266 ++++++++++++++++++
 .../merge_request_widget.js.coffee            | 143 ++++++++++
 .../projects/merge_requests_controller.rb     |  19 +-
 .../projects/commit/_pipelines_list.haml      |  54 ++++
 .../projects/merge_requests/_show.html.haml   |   6 +
 .../merge_requests/show/_builds.html.haml     |   1 -
 .../merge_requests/show/_pipelines.html.haml  |   1 +
 .../merge_requests/widget/_show.html.haml     |   3 +-
 config/routes.rb                              |   1 +
 9 files changed, 488 insertions(+), 6 deletions(-)
 create mode 100644 app/assets/javascripts/merge_request_tabs.js.coffee
 create mode 100644 app/assets/javascripts/merge_request_widget.js.coffee
 create mode 100644 app/views/projects/commit/_pipelines_list.haml
 create mode 100644 app/views/projects/merge_requests/show/_pipelines.html.haml

diff --git a/app/assets/javascripts/merge_request_tabs.js.coffee b/app/assets/javascripts/merge_request_tabs.js.coffee
new file mode 100644
index 00000000000..ccdfcf895a3
--- /dev/null
+++ b/app/assets/javascripts/merge_request_tabs.js.coffee
@@ -0,0 +1,266 @@
+# MergeRequestTabs
+#
+# Handles persisting and restoring the current tab selection and lazily-loading
+# content on the MergeRequests#show page.
+#
+#= require jquery.cookie
+#
+# ### Example Markup
+#
+#   <ul class="nav-links merge-request-tabs">
+#     <li class="notes-tab active">
+#       <a data-action="notes" data-target="#notes" data-toggle="tab" href="/foo/bar/merge_requests/1">
+#         Discussion
+#       </a>
+#     </li>
+#     <li class="commits-tab">
+#       <a data-action="commits" data-target="#commits" data-toggle="tab" href="/foo/bar/merge_requests/1/commits">
+#         Commits
+#       </a>
+#     </li>
+#     <li class="diffs-tab">
+#       <a data-action="diffs" data-target="#diffs" data-toggle="tab" href="/foo/bar/merge_requests/1/diffs">
+#         Diffs
+#       </a>
+#     </li>
+#   </ul>
+#
+#   <div class="tab-content">
+#     <div class="notes tab-pane active" id="notes">
+#       Notes Content
+#     </div>
+#     <div class="commits tab-pane" id="commits">
+#       Commits Content
+#     </div>
+#     <div class="diffs tab-pane" id="diffs">
+#       Diffs Content
+#     </div>
+#   </div>
+#
+#   <div class="mr-loading-status">
+#     <div class="loading">
+#       Loading Animation
+#     </div>
+#   </div>
+#
+class @MergeRequestTabs
+  diffsLoaded: false
+  buildsLoaded: false
+  commitsLoaded: false
+
+  constructor: (@opts = {}) ->
+    # Store the `location` object, allowing for easier stubbing in tests
+    @_location = location
+
+    @bindEvents()
+    @activateTab(@opts.action)
+
+  bindEvents: ->
+    $(document).on 'shown.bs.tab', '.merge-request-tabs a[data-toggle="tab"]', @tabShown
+    $(document).on 'click', '.js-show-tab', @showTab
+
+  showTab: (event) =>
+    event.preventDefault()
+
+    @activateTab $(event.target).data('action')
+
+  tabShown: (event) =>
+    $target = $(event.target)
+    action = $target.data('action')
+
+    if action == 'commits'
+      @loadCommits($target.attr('href'))
+      @expandView()
+    else if action == 'diffs'
+      @loadDiff($target.attr('href'))
+      if bp? and bp.getBreakpointSize() isnt 'lg'
+        @shrinkView()
+
+      navBarHeight = $('.navbar-gitlab').outerHeight()
+      $.scrollTo(".merge-request-details .merge-request-tabs", offset: -navBarHeight)
+    else if action == 'builds'
+      @loadBuilds($target.attr('href'))
+      @expandView()
+    else if action == 'pipelines'
+      @loadPipelines($target.attr('href'))
+      @expandView()
+    else
+      @expandView()
+
+    @setCurrentAction(action)
+
+  scrollToElement: (container) ->
+    if window.location.hash
+      navBarHeight = $('.navbar-gitlab').outerHeight() + $('.layout-nav').outerHeight()
+
+      $el = $("#{container} #{window.location.hash}:not(.match)")
+      $.scrollTo("#{container} #{window.location.hash}:not(.match)", offset: -navBarHeight) if $el.length
+
+  # Activate a tab based on the current action
+  activateTab: (action) ->
+    action = 'notes' if action == 'show'
+    $(".merge-request-tabs a[data-action='#{action}']").tab('show')
+
+  # Replaces the current Merge Request-specific action in the URL with a new one
+  #
+  # If the action is "notes", the URL is reset to the standard
+  # `MergeRequests#show` route.
+  #
+  # Examples:
+  #
+  #   location.pathname # => "/namespace/project/merge_requests/1"
+  #   setCurrentAction('diffs')
+  #   location.pathname # => "/namespace/project/merge_requests/1/diffs"
+  #
+  #   location.pathname # => "/namespace/project/merge_requests/1/diffs"
+  #   setCurrentAction('notes')
+  #   location.pathname # => "/namespace/project/merge_requests/1"
+  #
+  #   location.pathname # => "/namespace/project/merge_requests/1/diffs"
+  #   setCurrentAction('commits')
+  #   location.pathname # => "/namespace/project/merge_requests/1/commits"
+  #
+  # Returns the new URL String
+  setCurrentAction: (action) =>
+    # Normalize action, just to be safe
+    action = 'notes' if action == 'show'
+
+    # Remove a trailing '/commits' or '/diffs'
+    new_state = @_location.pathname.replace(/\/(commits|diffs|builds|pipelines)(\.html)?\/?$/, '')
+
+    # Append the new action if we're on a tab other than 'notes'
+    unless action == 'notes'
+      new_state += "/#{action}"
+
+    # Ensure parameters and hash come along for the ride
+    new_state += @_location.search + @_location.hash
+
+    # Replace the current history state with the new one without breaking
+    # Turbolinks' history.
+    #
+    # See https://github.com/rails/turbolinks/issues/363
+    history.replaceState {turbolinks: true, url: new_state}, document.title, new_state
+
+    new_state
+
+  loadCommits: (source) ->
+    return if @commitsLoaded
+
+    @_get
+      url: "#{source}.json"
+      success: (data) =>
+        document.querySelector("div#commits").innerHTML = data.html
+        gl.utils.localTimeAgo($('.js-timeago', 'div#commits'))
+        @commitsLoaded = true
+        @scrollToElement("#commits")
+
+  loadDiff: (source) ->
+    return if @diffsLoaded
+    @_get
+      url: "#{source}.json" + @_location.search
+      success: (data) =>
+        $('#diffs').html data.html
+        gl.utils.localTimeAgo($('.js-timeago', 'div#diffs'))
+        $('#diffs .js-syntax-highlight').syntaxHighlight()
+        $('#diffs .diff-file').singleFileDiff()
+        @expandViewContainer() if @diffViewType() is 'parallel'
+        @diffsLoaded = true
+        @scrollToElement("#diffs")
+        @highlighSelectedLine()
+        @filesCommentButton = $('.files .diff-file').filesCommentButton()
+
+        $(document)
+          .off 'click', '.diff-line-num a'
+          .on 'click', '.diff-line-num a', (e) =>
+            e.preventDefault()
+            window.location.hash = $(e.currentTarget).attr 'href'
+            @highlighSelectedLine()
+            @scrollToElement("#diffs")
+
+  highlighSelectedLine: ->
+    $('.hll').removeClass 'hll'
+    locationHash = window.location.hash
+
+    if locationHash isnt ''
+      hashClassString = ".#{locationHash.replace('#', '')}"
+      $diffLine = $("#{locationHash}:not(.match)", $('#diffs'))
+
+      if not $diffLine.is 'tr'
+        $diffLine = $('#diffs').find("td#{locationHash}, td#{hashClassString}")
+      else
+        $diffLine = $diffLine.find('td')
+
+      if $diffLine.length
+        $diffLine.addClass 'hll'
+        diffLineTop = $diffLine.offset().top
+        navBarHeight = $('.navbar-gitlab').outerHeight()
+
+  loadBuilds: (source) ->
+    return if @buildsLoaded
+
+    @_get
+      url: "#{source}.json"
+      success: (data) =>
+        document.querySelector("div#builds").innerHTML = data.html
+        gl.utils.localTimeAgo($('.js-timeago', 'div#builds'))
+        @buildsLoaded = true
+        @scrollToElement("#builds")
+
+  loadPipelines: (source) ->
+    return if @pipelinesLoaded
+
+    @_get
+      url: "#{source}.json"
+      success: (data) =>
+        document.querySelector("div#pipelines").innerHTML = data.html
+        gl.utils.localTimeAgo($('.js-timeago', 'div#pipelines'))
+        @pipelinesLoaded = true
+        @scrollToElement("#pipelines")
+
+  # Show or hide the loading spinner
+  #
+  # status - Boolean, true to show, false to hide
+  toggleLoading: (status) ->
+    $('.mr-loading-status .loading').toggle(status)
+
+  _get: (options) ->
+    defaults = {
+      beforeSend: => @toggleLoading(true)
+      complete:   => @toggleLoading(false)
+      dataType: 'json'
+      type: 'GET'
+    }
+
+    options = $.extend({}, defaults, options)
+
+    $.ajax(options)
+
+  # Returns diff view type
+  diffViewType: ->
+    $('.inline-parallel-buttons a.active').data('view-type')
+
+  expandViewContainer: ->
+    $('.container-fluid').removeClass('container-limited')
+
+  shrinkView: ->
+    $gutterIcon = $('.js-sidebar-toggle i:visible')
+
+    # Wait until listeners are set
+    setTimeout( ->
+      # Only when sidebar is expanded
+      if $gutterIcon.is('.fa-angle-double-right')
+        $gutterIcon.closest('a').trigger('click', [true])
+    , 0)
+
+  # Expand the issuable sidebar unless the user explicitly collapsed it
+  expandView: ->
+    return if $.cookie('collapsed_gutter') == 'true'
+
+    $gutterIcon = $('.js-sidebar-toggle i:visible')
+
+    # Wait until listeners are set
+    setTimeout( ->
+      # Only when sidebar is collapsed
+      if $gutterIcon.is('.fa-angle-double-left')
+        $gutterIcon.closest('a').trigger('click', [true])
+    , 0)
diff --git a/app/assets/javascripts/merge_request_widget.js.coffee b/app/assets/javascripts/merge_request_widget.js.coffee
new file mode 100644
index 00000000000..63a12582ddc
--- /dev/null
+++ b/app/assets/javascripts/merge_request_widget.js.coffee
@@ -0,0 +1,143 @@
+class @MergeRequestWidget
+  # Initialize MergeRequestWidget behavior
+  #
+  #   check_enable           - Boolean, whether to check automerge status
+  #   merge_check_url - String, URL to use to check automerge status
+  #   ci_status_url        - String, URL to use to check CI status
+  #
+
+  constructor: (@opts) ->
+    $('#modal_merge_info').modal(show: false)
+    @firstCICheck = true
+    @readyForCICheck = false
+    @cancel = false
+    clearInterval @fetchBuildStatusInterval
+
+    @clearEventListeners()
+    @addEventListeners()
+    @getCIStatus(false)
+    @pollCIStatus()
+    notifyPermissions()
+
+  clearEventListeners: ->
+    $(document).off 'page:change.merge_request'
+
+  cancelPolling: ->
+    @cancel = true
+
+  addEventListeners: ->
+    allowedPages = ['show', 'commits', 'builds', 'pipelines', 'changes']
+    $(document).on 'page:change.merge_request', =>
+      page = $('body').data('page').split(':').last()
+      if allowedPages.indexOf(page) < 0
+        clearInterval @fetchBuildStatusInterval
+        @cancelPolling()
+        @clearEventListeners()
+
+  mergeInProgress: (deleteSourceBranch = false)->
+    $.ajax
+      type: 'GET'
+      url: $('.merge-request').data('url')
+      success: (data) =>
+        if data.state == "merged"
+          urlSuffix = if deleteSourceBranch then '?delete_source=true' else ''
+
+          window.location.href = window.location.pathname + urlSuffix
+        else if data.merge_error
+          $('.mr-widget-body').html("<h4>" + data.merge_error + "</h4>")
+        else
+          callback = -> merge_request_widget.mergeInProgress(deleteSourceBranch)
+          setTimeout(callback, 2000)
+      dataType: 'json'
+
+  getMergeStatus: ->
+    $.get @opts.merge_check_url, (data) ->
+      $('.mr-state-widget').replaceWith(data)
+
+  ciLabelForStatus: (status) ->
+    switch status
+      when 'success'
+        'passed'
+      when 'success_with_warnings'
+        'passed with warnings'
+      else
+        status
+
+  pollCIStatus: ->
+    @fetchBuildStatusInterval = setInterval ( =>
+      return if not @readyForCICheck
+
+      @getCIStatus(true)
+
+      @readyForCICheck = false
+    ), 10000
+
+  getCIStatus: (showNotification) ->
+    _this = @
+    $('.ci-widget-fetching').show()
+
+    $.getJSON @opts.ci_status_url, (data) =>
+      return if @cancel
+      @readyForCICheck = true
+
+      if data.status is ''
+        return
+
+      if @firstCICheck || data.status isnt @opts.ci_status and data.status?
+        @opts.ci_status = data.status
+        @showCIStatus data.status
+        if data.coverage
+          @showCICoverage data.coverage
+
+        # The first check should only update the UI, a notification
+        # should only be displayed on status changes
+        if showNotification and not @firstCICheck
+          status = @ciLabelForStatus(data.status)
+
+          if status is "preparing"
+            title = @opts.ci_title.preparing
+            status = status.charAt(0).toUpperCase() + status.slice(1);
+            message = @opts.ci_message.preparing.replace('{{status}}', status)
+          else
+            title = @opts.ci_title.normal
+            message = @opts.ci_message.normal.replace('{{status}}', status)
+
+          title = title.replace('{{status}}', status)
+          message = message.replace('{{sha}}', data.sha)
+          message = message.replace('{{title}}', data.title)
+
+          notify(
+            title,
+            message,
+            @opts.gitlab_icon,
+            ->
+              @close()
+              Turbolinks.visit _this.opts.builds_path
+          )
+        @firstCICheck = false
+
+  showCIStatus: (state) ->
+    return if not state?
+    $('.ci_widget').hide()
+    allowed_states = ["failed", "canceled", "running", "pending", "success", "success_with_warnings", "skipped", "not_found"]
+    if state in allowed_states
+      $('.ci_widget.ci-' + state).show()
+      switch state
+        when "failed", "canceled", "not_found"
+          @setMergeButtonClass('btn-danger')
+        when "running"
+          @setMergeButtonClass('btn-warning')
+        when "success", "success_with_warnings"
+          @setMergeButtonClass('btn-create')
+    else
+      $('.ci_widget.ci-error').show()
+      @setMergeButtonClass('btn-danger')
+
+  showCICoverage: (coverage) ->
+    text = 'Coverage ' + coverage + '%'
+    $('.ci_widget:visible .ci-coverage').text(text)
+
+  setMergeButtonClass: (css_class) ->
+    $('.js-merge-button,.accept-action .dropdown-toggle')
+      .removeClass('btn-danger btn-warning btn-create')
+      .addClass(css_class)
diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb
index 594a61464b9..5c6396fba9f 100644
--- a/app/controllers/projects/merge_requests_controller.rb
+++ b/app/controllers/projects/merge_requests_controller.rb
@@ -7,15 +7,15 @@ class Projects::MergeRequestsController < Projects::ApplicationController
 
   before_action :module_enabled
   before_action :merge_request, only: [
-    :edit, :update, :show, :diffs, :commits, :builds, :merge, :merge_check,
+    :edit, :update, :show, :diffs, :commits, :builds, :pipelines, :merge, :merge_check,
     :ci_status, :toggle_subscription, :cancel_merge_when_build_succeeds, :remove_wip
   ]
-  before_action :validates_merge_request, only: [:show, :diffs, :commits, :builds]
-  before_action :define_show_vars, only: [:show, :diffs, :commits, :builds]
+  before_action :validates_merge_request, only: [:show, :diffs, :commits, :builds, :pipelines]
+  before_action :define_show_vars, only: [:show, :diffs, :commits, :builds, :pipelines]
   before_action :define_widget_vars, only: [:merge, :cancel_merge_when_build_succeeds, :merge_check]
   before_action :define_commit_vars, only: [:diffs]
   before_action :define_diff_comment_vars, only: [:diffs]
-  before_action :ensure_ref_fetched, only: [:show, :diffs, :commits, :builds]
+  before_action :ensure_ref_fetched, only: [:show, :diffs, :commits, :builds, :pipelines]
 
   # Allow read any merge_request
   before_action :authorize_read_merge_request!
@@ -136,6 +136,17 @@ class Projects::MergeRequestsController < Projects::ApplicationController
     end
   end
 
+  def pipelines
+    respond_to do |format|
+      format.html do
+        define_discussion_vars
+
+        render 'show'
+      end
+      format.json { render json: { html: view_to_html_string('projects/merge_requests/show/_pipelines') } }
+    end
+  end
+
   def new
     build_merge_request
     @noteable = @merge_request
diff --git a/app/views/projects/commit/_pipelines_list.haml b/app/views/projects/commit/_pipelines_list.haml
new file mode 100644
index 00000000000..2c2feced657
--- /dev/null
+++ b/app/views/projects/commit/_pipelines_list.haml
@@ -0,0 +1,54 @@
+- status = pipeline.status
+
+%ul.content-list.pipelines
+
+  .table-holder
+    %table.table.builds
+      %tbody
+        %th Status
+        %th Commit
+        %th.stage
+        %th
+        %th
+      %tr.commit
+        %td.commit-link
+          = link_to namespace_project_pipeline_path(@project.namespace, @project, pipeline.id) do
+            = ci_status_with_icon(status)
+        %td
+          .branch-commit
+            = link_to namespace_project_pipeline_path(@project.namespace, @project, pipeline.id) do
+              %span ##{pipeline.id}
+            - if pipeline.ref
+              .icon-container
+                = pipeline.tag? ? icon('tag') : icon('code-fork')
+              = link_to pipeline.ref, namespace_project_commits_path(@project.namespace, @project, pipeline.ref), class: "monospace branch-name"
+              .icon-container
+                = custom_icon("icon_commit")
+            = link_to pipeline.short_sha, namespace_project_commit_path(@project.namespace, @project, pipeline.sha), class: "commit-id monospace"
+            - if pipeline.latest?
+              %span.label.label-success.has-tooltip{ title: 'Latest build for this branch' } latest
+            - if pipeline.triggered?
+              %span.label.label-primary triggered
+            - if pipeline.yaml_errors.present?
+              %span.label.label-danger.has-tooltip{ title: "#{pipeline.yaml_errors}" } yaml invalid
+            - if pipeline.builds.any?(&:stuck?)
+              %span.label.label-warning stuck
+
+            %p.commit-title
+              - if commit = pipeline.commit
+                = author_avatar(commit, size: 20)
+                = link_to_gfm truncate(commit.title, length: 60), namespace_project_commit_path(@project.namespace, @project, commit.id), class: "commit-row-message"
+              - else
+                Cant find HEAD commit for this branch
+
+          -# - stages_status = pipeline.statuses.latest.stages_status
+          -# - stages.each do |stage|
+          -#   %td.stage-cell
+          -#     - status = stages_status[stage]
+          -#     - tooltip = "#{stage.titleize}: #{status || 'not found'}"
+          -#     - if status
+          -#       = link_to namespace_project_pipeline_path(@project.namespace, @project, pipeline.id, anchor: stage), class: "has-tooltip ci-status-icon-#{status}", title: tooltip do
+          -#         = ci_icon_for_status(status)
+          -#     - else
+          -#       .light.has-tooltip{ title: tooltip }
+          -#         \-
diff --git a/app/views/projects/merge_requests/_show.html.haml b/app/views/projects/merge_requests/_show.html.haml
index 873ed9b59ee..a78407f26ea 100644
--- a/app/views/projects/merge_requests/_show.html.haml
+++ b/app/views/projects/merge_requests/_show.html.haml
@@ -53,6 +53,10 @@
             Commits
             %span.badge= @commits_count
         - if @pipeline
+          %li.pipelines-tab
+            = link_to pipelines_namespace_project_merge_request_path(@project.namespace, @project, @merge_request), data: {target: '#pipelines', action: 'pipelines', toggle: 'tab'} do
+              Pipelines
+              %span.badge= @statuses.size
           %li.builds-tab
             = link_to builds_namespace_project_merge_request_path(@project.namespace, @project, @merge_request), data: {target: '#builds', action: 'builds', toggle: 'tab'} do
               Builds
@@ -76,6 +80,8 @@
           - # This tab is always loaded via AJAX
         #builds.builds.tab-pane
           - # This tab is always loaded via AJAX
+        #pipelines.pipelines.tab-pane
+          - # This tab is always loaded via AJAX
         #diffs.diffs.tab-pane
           - # This tab is always loaded via AJAX
 
diff --git a/app/views/projects/merge_requests/show/_builds.html.haml b/app/views/projects/merge_requests/show/_builds.html.haml
index 81de60f116c..808ef7fed27 100644
--- a/app/views/projects/merge_requests/show/_builds.html.haml
+++ b/app/views/projects/merge_requests/show/_builds.html.haml
@@ -1,2 +1 @@
 = render "projects/commit/pipeline", pipeline: @pipeline, link_to_commit: true
-
diff --git a/app/views/projects/merge_requests/show/_pipelines.html.haml b/app/views/projects/merge_requests/show/_pipelines.html.haml
new file mode 100644
index 00000000000..bcaec137371
--- /dev/null
+++ b/app/views/projects/merge_requests/show/_pipelines.html.haml
@@ -0,0 +1 @@
+= render "projects/commit/pipelines_list", pipeline: @pipeline, link_to_commit: true
diff --git a/app/views/projects/merge_requests/widget/_show.html.haml b/app/views/projects/merge_requests/widget/_show.html.haml
index d9efe81701f..ea618263a4a 100644
--- a/app/views/projects/merge_requests/widget/_show.html.haml
+++ b/app/views/projects/merge_requests/widget/_show.html.haml
@@ -23,7 +23,8 @@
       preparing: "{{status}} build",
       normal: "Build {{status}}"
     },
-    builds_path: "#{builds_namespace_project_merge_request_path(@project.namespace, @project, @merge_request)}"
+    builds_path: "#{builds_namespace_project_merge_request_path(@project.namespace, @project, @merge_request)}",
+    pipelines_path: "#{pipelines_namespace_project_merge_request_path(@project.namespace, @project, @merge_request)}"
   };
 
   if (typeof merge_request_widget !== 'undefined') {
diff --git a/config/routes.rb b/config/routes.rb
index 21f3585bacd..84a89200111 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -704,6 +704,7 @@ Rails.application.routes.draw do
             get :commits
             get :diffs
             get :builds
+            get :pipelines
             get :merge_check
             post :merge
             post :cancel_merge_when_build_succeeds
-- 
GitLab