summaryrefslogtreecommitdiff
path: root/app/services/quick_actions/interpret_service.rb
blob: 1baa4ddf0ebbfb6cfea8576ed4bfca069cd60abb (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
# frozen_string_literal: true

module QuickActions
  class InterpretService < BaseService
    include Gitlab::Utils::StrongMemoize
    include Gitlab::QuickActions::Dsl
    include Gitlab::QuickActions::IssueActions
    include Gitlab::QuickActions::IssuableActions
    include Gitlab::QuickActions::IssueAndMergeRequestActions
    include Gitlab::QuickActions::MergeRequestActions
    include Gitlab::QuickActions::CommitActions
    include Gitlab::QuickActions::CommonActions
    include Gitlab::QuickActions::RelateActions

    attr_reader :quick_action_target

    # Counts how many commands have been executed.
    # Used to display relevant feedback on UI when a note
    # with only commands has been processed.
    attr_accessor :commands_executed_count

    # Takes an quick_action_target and returns an array of all the available commands
    # represented with .to_h
    def available_commands(quick_action_target)
      @quick_action_target = quick_action_target

      self.class.command_definitions.map do |definition|
        next unless definition.available?(self)

        definition.to_h(self)
      end.compact
    end

    # Takes a text and interprets the commands that are extracted from it.
    # Returns the content without commands, a hash of changes to be applied to a record
    # and a string containing the execution_message to show to the user.
    def execute(content, quick_action_target, only: nil)
      return [content, {}, ''] unless current_user.can?(:use_quick_actions)

      @quick_action_target = quick_action_target
      @updates = {}
      @execution_message = {}

      content, commands = extractor.extract_commands(content, only: only)
      extract_updates(commands)

      [content, @updates, execution_messages_for(commands)]
    end

    # Takes a text and interprets the commands that are extracted from it.
    # Returns the content without commands, and array of changes explained.
    def explain(content, quick_action_target)
      return [content, []] unless current_user.can?(:use_quick_actions)

      @quick_action_target = quick_action_target

      content, commands = extractor.extract_commands(content)
      commands = explain_commands(commands)
      [content, commands]
    end

    private

    def failed_parse(message)
      raise Gitlab::QuickActions::CommandDefinition::ParseError, message
    end

    def extractor
      Gitlab::QuickActions::Extractor.new(self.class.command_definitions)
    end

    def extract_users(params)
      return [] if params.blank?

      # We are using the a simple User.by_username query here rather than a ReferenceExtractor
      # because the needs here are much simpler: we only deal in usernames, and
      # want to also handle bare usernames. The ReferenceExtractor also has
      # different behaviour, and will return all group members for groups named
      # using a user-style reference, which is not in scope here.
      args        = params.split(/\s|,/).select(&:present?).uniq - ['and']
      usernames   = (args - ['me']).map { _1.delete_prefix('@') }
      found       = User.by_username(usernames).to_a.select { can?(:read_user, _1) }
      found_names = found.map(&:username).to_set
      missing     = args.reject { |arg| arg == 'me' || found_names.include?(arg.delete_prefix('@')) }.map { "'#{_1}'" }

      failed_parse(format(_("Failed to find users for %{missing}"), missing: missing.to_sentence)) if missing.present?

      found + [current_user].select { args.include?('me') }
    end

    def find_milestones(project, params = {})
      group_ids = project.group.self_and_ancestors.select(:id) if project.group

      MilestonesFinder.new(params.merge(project_ids: [project.id], group_ids: group_ids)).execute
    end

    def parent
      project || group
    end

    def group
      strong_memoize(:group) do
        quick_action_target.group if quick_action_target.respond_to?(:group)
      end
    end

    def find_labels(labels_params = nil)
      extract_references(labels_params, :label) | find_labels_by_name_no_tilde(labels_params)
    end

    def find_labels_by_name_no_tilde(labels_params)
      return Label.none if label_with_tilde?(labels_params)

      finder_params = { include_ancestor_groups: true }
      finder_params[:project_id] = project.id if project
      finder_params[:group_id] = group.id if group
      finder_params[:name] = extract_label_names(labels_params) if labels_params

      LabelsFinder.new(current_user, finder_params).execute
    end

    def label_with_tilde?(labels_params)
      labels_params&.include?('~')
    end

    def extract_label_names(labels_params)
      # '"A" "A B C" A B' => ["A", "A B C", "A", "B"]
      labels_params.scan(/"([^"]+)"|([^ ]+)/).flatten.compact
    end

    def find_label_references(labels_param, format = :id)
      labels_to_reference(find_labels(labels_param), format)
    end

    def labels_to_reference(labels, format = :id)
      labels.map { |l| l.to_reference(format: format) }
    end

    def find_label_ids(labels_param)
      find_labels(labels_param).map(&:id)
    end

    def explain_commands(commands)
      map_commands(commands, :explain)
    end

    def execution_messages_for(commands)
      map_commands(commands, :execute_message).join(' ')
    end

    def map_commands(commands, method)
      commands.map do |name, arg|
        definition = self.class.definition_by_name(name)
        next unless definition

        case method
        when :explain
          definition.explain(self, arg)
        when :execute_message
          @execution_message[name.to_sym] || definition.execute_message(self, arg)
        end
      end.compact
    end

    def extract_updates(commands)
      commands.each do |name, arg|
        definition = self.class.definition_by_name(name)
        next unless definition

        definition.execute(self, arg)
        usage_ping_tracking(name, arg)
      end
    end

    # rubocop: disable CodeReuse/ActiveRecord
    def extract_references(arg, type)
      return [] unless arg

      ext = Gitlab::ReferenceExtractor.new(project, current_user)

      ext.analyze(arg, author: current_user, group: group)

      ext.references(type)
    end
    # rubocop: enable CodeReuse/ActiveRecord

    def usage_ping_tracking(quick_action_name, arg)
      Gitlab::UsageDataCounters::QuickActionActivityUniqueCounter.track_unique_action(
        quick_action_name,
        args: arg&.strip,
        user: current_user
      )
    end

    def can?(ability, object)
      Ability.allowed?(current_user, ability, object)
    end
  end
end

QuickActions::InterpretService.prepend_mod_with('QuickActions::InterpretService')