interpret_service.rb 17.1 KB
Newer Older
1
module QuickActions
2
  class InterpretService < BaseService
3
    include Gitlab::QuickActions::Dsl
4

5
    attr_reader :issuable
6

7 8 9
    SHRUG = '¯\\_(ツ)_/¯'.freeze
    TABLEFLIP = '(╯°□°)╯︵ ┻━┻'.freeze

10 11
    # Takes a text and interprets the commands that are extracted from it.
    # Returns the content without commands, and hash of changes to be applied to a record.
12
    def execute(content, issuable)
13
      return [content, {}] unless current_user.can?(:use_quick_actions)
14

15
      @issuable = issuable
16 17
      @updates = {}

18 19
      content, commands = extractor.extract_commands(content, context)
      extract_updates(commands, context)
20

21 22
      [content, @updates]
    end
D
Douwe Maan 已提交
23

24 25 26
    # 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, issuable)
27
      return [content, []] unless current_user.can?(:use_quick_actions)
D
Douwe Maan 已提交
28

29
      @issuable = issuable
30

31 32 33
      content, commands = extractor.extract_commands(content, context)
      commands = explain_commands(commands, context)
      [content, commands]
34 35 36 37
    end

    private

D
Douwe Maan 已提交
38
    def extractor
39
      Gitlab::QuickActions::Extractor.new(self.class.command_definitions)
40 41
    end

42
    desc do
43
      "Close this #{issuable.to_ability_name.humanize(capitalize: false)}"
44
    end
45 46 47
    explanation do
      "Closes this #{issuable.to_ability_name.humanize(capitalize: false)}."
    end
48
    condition do
49 50 51
      issuable.persisted? &&
        issuable.open? &&
        current_user.can?(:"update_#{issuable.to_ability_name}", issuable)
R
Rémy Coutable 已提交
52
    end
53 54 55 56
    command :close do
      @updates[:state_event] = 'close'
    end

57
    desc do
58
      "Reopen this #{issuable.to_ability_name.humanize(capitalize: false)}"
59
    end
60 61 62
    explanation do
      "Reopens this #{issuable.to_ability_name.humanize(capitalize: false)}."
    end
63
    condition do
64 65 66
      issuable.persisted? &&
        issuable.closed? &&
        current_user.can?(:"update_#{issuable.to_ability_name}", issuable)
R
Rémy Coutable 已提交
67
    end
D
Douwe Maan 已提交
68
    command :reopen do
69 70 71
      @updates[:state_event] = 'reopen'
    end

J
James Lopez 已提交
72
    desc 'Merge (when the pipeline succeeds)'
73
    explanation 'Merges this merge request when the pipeline succeeds.'
74
    condition do
J
Jarka Kadlecova 已提交
75
      last_diff_sha = params && params[:merge_request_diff_head_sha]
76
      issuable.is_a?(MergeRequest) &&
J
Jarka Kadlecova 已提交
77
        issuable.persisted? &&
78
        issuable.mergeable_with_quick_action?(current_user, autocomplete_precheck: !last_diff_sha, last_diff_sha: last_diff_sha)
79 80 81 82 83
    end
    command :merge do
      @updates[:merge] = params[:merge_request_diff_head_sha]
    end

R
Rémy Coutable 已提交
84
    desc 'Change title'
85 86 87
    explanation do |title_param|
      "Changes the title to \"#{title_param}\"."
    end
R
Rémy Coutable 已提交
88
    params '<New title>'
89
    condition do
90 91
      issuable.persisted? &&
        current_user.can?(:"update_#{issuable.to_ability_name}", issuable)
R
Rémy Coutable 已提交
92
    end
R
Rémy Coutable 已提交
93 94 95 96
    command :title do |title_param|
      @updates[:title] = title_param
    end

97
    desc 'Assign'
98
    explanation do |users|
99 100 101 102 103
      users = issuable.allows_multiple_assignees? ? users : users.take(1)
      "Assigns #{users.map(&:to_reference).to_sentence}."
    end
    params do
      issuable.allows_multiple_assignees? ? '@user1 @user2' : '@user'
104
    end
105
    condition do
106
      current_user.can?(:"admin_#{issuable.to_ability_name}", project)
R
Rémy Coutable 已提交
107
    end
108
    parse_params do |assignee_param|
109
      extract_users(assignee_param)
110 111 112
    end
    command :assign do |users|
      next if users.empty?
113

114 115 116 117
      @updates[:assignee_ids] =
        if issuable.allows_multiple_assignees?
          issuable.assignees.pluck(:id) + users.map(&:id)
        else
118
          [users.first.id]
119
        end
120 121
    end

122 123 124 125 126 127 128
    desc do
      if issuable.allows_multiple_assignees?
        'Remove all or specific assignee(s)'
      else
        'Remove assignee'
      end
    end
129
    explanation do
130 131 132 133
      "Removes #{'assignee'.pluralize(issuable.assignees.size)} #{issuable.assignees.map(&:to_reference).to_sentence}."
    end
    params do
      issuable.allows_multiple_assignees? ? '@user1 @user2' : ''
134
    end
135
    condition do
136
      issuable.persisted? &&
137
        issuable.assignees.any? &&
138
        current_user.can?(:"admin_#{issuable.to_ability_name}", project)
R
Rémy Coutable 已提交
139
    end
140
    parse_params do |unassign_param|
141
      # When multiple users are assigned, all will be unassigned if multiple assignees are no longer allowed
142 143 144
      extract_users(unassign_param) if issuable.allows_multiple_assignees?
    end
    command :unassign do |users = nil|
145 146 147 148 149 150
      @updates[:assignee_ids] =
        if users&.any?
          issuable.assignees.pluck(:id) - users.map(&:id)
        else
          []
        end
151 152
    end

153
    desc 'Set milestone'
154 155 156
    explanation do |milestone|
      "Sets the milestone to #{milestone.to_reference}." if milestone
    end
157
    params '%"milestone"'
158
    condition do
159
      current_user.can?(:"admin_#{issuable.to_ability_name}", project) &&
160
        project.milestones.active.any?
R
Rémy Coutable 已提交
161
    end
162 163 164 165 166
    parse_params do |milestone_param|
      extract_references(milestone_param, :milestone).first ||
        project.milestones.find_by(title: milestone_param.strip)
    end
    command :milestone do |milestone|
167
      @updates[:milestone_id] = milestone.id if milestone
168 169 170
    end

    desc 'Remove milestone'
171 172 173
    explanation do
      "Removes #{issuable.milestone.to_reference(format: :name)} milestone."
    end
174
    condition do
175 176 177
      issuable.persisted? &&
        issuable.milestone_id? &&
        current_user.can?(:"admin_#{issuable.to_ability_name}", project)
R
Rémy Coutable 已提交
178
    end
D
Douwe Maan 已提交
179
    command :remove_milestone do
180 181 182 183
      @updates[:milestone_id] = nil
    end

    desc 'Add label(s)'
184 185 186 187 188
    explanation do |labels_param|
      labels = find_label_references(labels_param)

      "Adds #{labels.join(' ')} #{'label'.pluralize(labels.count)}." if labels.any?
    end
189
    params '~label1 ~"label 2"'
190
    condition do
191 192
      available_labels = LabelsFinder.new(current_user, project_id: project.id).execute

193
      current_user.can?(:"admin_#{issuable.to_ability_name}", project) &&
194
        available_labels.any?
R
Rémy Coutable 已提交
195
    end
D
Douwe Maan 已提交
196
    command :label do |labels_param|
197 198
      label_ids = find_label_ids(labels_param)

B
barthc 已提交
199 200 201 202 203 204
      if label_ids.any?
        @updates[:add_label_ids] ||= []
        @updates[:add_label_ids] += label_ids

        @updates[:add_label_ids].uniq!
      end
205 206
    end

D
Douwe Maan 已提交
207
    desc 'Remove all or specific label(s)'
208 209 210 211 212 213 214 215
    explanation do |labels_param = nil|
      if labels_param.present?
        labels = find_label_references(labels_param)
        "Removes #{labels.join(' ')} #{'label'.pluralize(labels.count)}." if labels.any?
      else
        'Removes all labels.'
      end
    end
216
    params '~label1 ~"label 2"'
217
    condition do
218 219 220
      issuable.persisted? &&
        issuable.labels.any? &&
        current_user.can?(:"admin_#{issuable.to_ability_name}", project)
R
Rémy Coutable 已提交
221
    end
D
Douwe Maan 已提交
222 223 224
    command :unlabel do |labels_param = nil|
      if labels_param.present?
        label_ids = find_label_ids(labels_param)
225

B
barthc 已提交
226 227 228 229 230 231
        if label_ids.any?
          @updates[:remove_label_ids] ||= []
          @updates[:remove_label_ids] += label_ids

          @updates[:remove_label_ids].uniq!
        end
D
Douwe Maan 已提交
232 233 234
      else
        @updates[:label_ids] = []
      end
235 236
    end

D
Douwe Maan 已提交
237
    desc 'Replace all label(s)'
238 239 240 241
    explanation do |labels_param|
      labels = find_label_references(labels_param)
      "Replaces all labels with #{labels.join(' ')} #{'label'.pluralize(labels.count)}." if labels.any?
    end
D
Douwe Maan 已提交
242
    params '~label1 ~"label 2"'
243
    condition do
244 245 246
      issuable.persisted? &&
        issuable.labels.any? &&
        current_user.can?(:"admin_#{issuable.to_ability_name}", project)
R
Rémy Coutable 已提交
247
    end
D
Douwe Maan 已提交
248 249 250
    command :relabel do |labels_param|
      label_ids = find_label_ids(labels_param)

B
barthc 已提交
251 252 253 254 255 256
      if label_ids.any?
        @updates[:label_ids] ||= []
        @updates[:label_ids] += label_ids

        @updates[:label_ids].uniq!
      end
257 258 259
    end

    desc 'Add a todo'
260
    explanation 'Adds a todo.'
261
    condition do
262 263
      issuable.persisted? &&
        !TodoService.new.todo_exist?(issuable, current_user)
R
Rémy Coutable 已提交
264
    end
265
    command :todo do
266
      @updates[:todo_event] = 'add'
267 268 269
    end

    desc 'Mark todo as done'
270
    explanation 'Marks todo as done.'
271
    condition do
272 273
      issuable.persisted? &&
        TodoService.new.todo_exist?(issuable, current_user)
R
Rémy Coutable 已提交
274
    end
275 276 277 278 279
    command :done do
      @updates[:todo_event] = 'done'
    end

    desc 'Subscribe'
280 281 282
    explanation do
      "Subscribes to this #{issuable.to_ability_name.humanize(capitalize: false)}."
    end
283
    condition do
284
      issuable.persisted? &&
285
        !issuable.subscribed?(current_user, project)
R
Rémy Coutable 已提交
286
    end
287 288 289 290 291
    command :subscribe do
      @updates[:subscription_event] = 'subscribe'
    end

    desc 'Unsubscribe'
292 293 294
    explanation do
      "Unsubscribes from this #{issuable.to_ability_name.humanize(capitalize: false)}."
    end
295
    condition do
296
      issuable.persisted? &&
297
        issuable.subscribed?(current_user, project)
R
Rémy Coutable 已提交
298
    end
299 300 301 302
    command :unsubscribe do
      @updates[:subscription_event] = 'unsubscribe'
    end

R
Rémy Coutable 已提交
303
    desc 'Set due date'
304 305 306
    explanation do |due_date|
      "Sets the due date to #{due_date.to_s(:medium)}." if due_date
    end
D
Douwe Maan 已提交
307
    params '<in 2 days | this Friday | December 31st>'
308
    condition do
309
      issuable.respond_to?(:due_date) &&
310
        current_user.can?(:"admin_#{issuable.to_ability_name}", project)
R
Rémy Coutable 已提交
311
    end
312 313 314 315
    parse_params do |due_date_param|
      Chronic.parse(due_date_param).try(:to_date)
    end
    command :due do |due_date|
316 317 318 319
      @updates[:due_date] = due_date if due_date
    end

    desc 'Remove due date'
320
    explanation 'Removes the due date.'
321
    condition do
322 323 324
      issuable.persisted? &&
        issuable.respond_to?(:due_date) &&
        issuable.due_date? &&
325
        current_user.can?(:"admin_#{issuable.to_ability_name}", project)
R
Rémy Coutable 已提交
326
    end
D
Douwe Maan 已提交
327
    command :remove_due_date do
328 329 330
      @updates[:due_date] = nil
    end

331 332 333 334 335
    desc 'Toggle the Work In Progress status'
    explanation do
      verb = issuable.work_in_progress? ? 'Unmarks' : 'Marks'
      noun = issuable.to_ability_name.humanize(capitalize: false)
      "#{verb} this #{noun} as Work In Progress."
T
Thomas Balthazar 已提交
336 337 338 339 340 341 342 343 344 345
    end
    condition do
      issuable.persisted? &&
        issuable.respond_to?(:work_in_progress?) &&
        current_user.can?(:"update_#{issuable.to_ability_name}", issuable)
    end
    command :wip do
      @updates[:wip_event] = issuable.work_in_progress? ? 'unwip' : 'wip'
    end

346 347 348 349
    desc 'Toggle emoji award'
    explanation do |name|
      "Toggles :#{name}: emoji award." if name
    end
M
mhasbini 已提交
350 351 352 353
    params ':emoji:'
    condition do
      issuable.persisted?
    end
354 355 356 357 358
    parse_params do |emoji_param|
      match = emoji_param.match(Banzai::Filter::EmojiFilter.emoji_pattern)
      match[1] if match
    end
    command :award do |name|
M
mhasbini 已提交
359 360 361 362 363
      if name && issuable.user_can_award?(current_user, name)
        @updates[:emoji_award] = name
      end
    end

364
    desc 'Set time estimate'
365 366 367 368 369
    explanation do |time_estimate|
      time_estimate = Gitlab::TimeTrackingFormatter.output(time_estimate)

      "Sets time estimate to #{time_estimate}." if time_estimate
    end
370 371 372 373
    params '<1w 3d 2h 14m>'
    condition do
      current_user.can?(:"admin_#{issuable.to_ability_name}", project)
    end
374 375 376 377
    parse_params do |raw_duration|
      Gitlab::TimeTrackingFormatter.parse(raw_duration)
    end
    command :estimate do |time_estimate|
378 379 380 381 382 383
      if time_estimate
        @updates[:time_estimate] = time_estimate
      end
    end

    desc 'Add or substract spent time'
V
Vlad 已提交
384
    explanation do |time_spent, time_spent_date|
385 386 387 388 389 390 391 392 393 394 395 396
      if time_spent
        if time_spent > 0
          verb = 'Adds'
          value = time_spent
        else
          verb = 'Substracts'
          value = -time_spent
        end

        "#{verb} #{Gitlab::TimeTrackingFormatter.output(value)} spent time."
      end
    end
V
Vlad 已提交
397
    params '<time(1h30m | -1h30m)> <date(YYYY-MM-DD)>'
398 399 400
    condition do
      current_user.can?(:"admin_#{issuable.to_ability_name}", issuable)
    end
V
Vlad 已提交
401 402
    parse_params do |raw_time_date|
      Gitlab::QuickActions::SpendTimeAndDateSeparator.new(raw_time_date).execute
403
    end
V
Vlad 已提交
404
    command :spend do |time_spent, time_spent_date|
405
      if time_spent
V
Vlad 已提交
406 407 408 409 410
        @updates[:spend_time] = {
          duration: time_spent,
          user: current_user,
          spent_at: time_spent_date
        }
411 412 413 414
      end
    end

    desc 'Remove time estimate'
415
    explanation 'Removes time estimate.'
416 417 418 419 420 421 422 423 424
    condition do
      issuable.persisted? &&
        current_user.can?(:"admin_#{issuable.to_ability_name}", project)
    end
    command :remove_estimate do
      @updates[:time_estimate] = 0
    end

    desc 'Remove spent time'
425
    explanation 'Removes spent time.'
426 427 428 429 430
    condition do
      issuable.persisted? &&
        current_user.can?(:"admin_#{issuable.to_ability_name}", project)
    end
    command :remove_time_spent do
431
      @updates[:spend_time] = { duration: :reset, user: current_user }
432 433
    end

434 435 436 437 438 439 440 441 442 443 444 445
    desc "Append the comment with #{SHRUG}"
    params '<Comment>'
    substitution :shrug do |comment|
      "#{comment} #{SHRUG}"
    end

    desc "Append the comment with #{TABLEFLIP}"
    params '<Comment>'
    substitution :tableflip do |comment|
      "#{comment} #{TABLEFLIP}"
    end

446 447 448
    # This is a dummy command, so that it appears in the autocomplete commands
    desc 'CC'
    params '@user'
449
    command :cc
450

451
    desc 'Set target branch'
452 453 454
    explanation do |branch_name|
      "Sets target branch to #{branch_name}."
    end
455 456 457 458 459 460
    params '<Local branch name>'
    condition do
      issuable.respond_to?(:target_branch) &&
        (current_user.can?(:"update_#{issuable.to_ability_name}", issuable) ||
          issuable.new_record?)
    end
461 462 463 464
    parse_params do |target_branch_param|
      target_branch_param.strip
    end
    command :target_branch do |branch_name|
465
      @updates[:target_branch] = branch_name if project.repository.branch_exists?(branch_name)
466 467
    end

A
Alex Sanford 已提交
468
    desc 'Move issue from one column of the board to another'
469 470 471 472
    explanation do |target_list_name|
      label = find_label_references(target_list_name).first
      "Moves issue to #{label} column in the board." if label
    end
A
Alex Sanford 已提交
473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493
    params '~"Target column"'
    condition do
      issuable.is_a?(Issue) &&
        current_user.can?(:"update_#{issuable.to_ability_name}", issuable) &&
        issuable.project.boards.count == 1
    end
    command :board_move do |target_list_name|
      label_ids = find_label_ids(target_list_name)

      if label_ids.size == 1
        label_id = label_ids.first

        # Ensure this label corresponds to a list on the board
        next unless Label.on_project_boards(issuable.project_id).where(id: label_id).exists?

        @updates[:remove_label_ids] =
          issuable.labels.on_project_boards(issuable.project_id).where.not(id: label_id).pluck(:id)
        @updates[:add_label_ids] = [label_id]
      end
    end

494
    desc 'Mark this issue as a duplicate of another issue'
495 496 497
    explanation do |duplicate_reference|
      "Marks this issue as a duplicate of #{duplicate_reference}."
    end
498 499 500 501 502 503 504
    params '#issue'
    condition do
      issuable.is_a?(Issue) &&
        issuable.persisted? &&
        current_user.can?(:"update_#{issuable.to_ability_name}", issuable)
    end
    command :duplicate do |duplicate_param|
505 506 507 508
      canonical_issue = extract_references(duplicate_param, :issue).first

      if canonical_issue.present?
        @updates[:canonical_issue_id] = canonical_issue.id
509 510 511
      end
    end

512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529
    desc 'Move this issue to another project.'
    explanation do |path_to_project|
      "Moves this issue to #{path_to_project}."
    end
    params 'path/to/project'
    condition do
      issuable.is_a?(Issue) &&
        issuable.persisted? &&
        current_user.can?(:"admin_#{issuable.to_ability_name}", project)
    end
    command :move do |target_project_path|
      target_project = Project.find_by_full_path(target_project_path)

      if target_project.present?
        @updates[:target_project] = target_project
      end
    end

530 531 532 533 534 535
    def extract_users(params)
      return [] if params.nil?

      users = extract_references(params, :user)

      if users.empty?
536 537 538 539 540 541
        users =
          if params == 'me'
            [current_user]
          else
            User.where(username: params.split(' ').map(&:strip))
          end
542 543 544 545 546
      end

      users
    end

547 548 549 550 551 552 553 554 555
    def find_labels(labels_param)
      extract_references(labels_param, :label) |
        LabelsFinder.new(current_user, project_id: project.id, name: labels_param.split).execute
    end

    def find_label_references(labels_param)
      find_labels(labels_param).map(&:to_reference)
    end

556
    def find_label_ids(labels_param)
557 558
      find_labels(labels_param).map(&:id)
    end
559

560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575
    def explain_commands(commands, opts)
      commands.map do |name, arg|
        definition = self.class.definition_by_name(name)
        next unless definition

        definition.explain(self, opts, arg)
      end.compact
    end

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

        definition.execute(self, opts, arg)
      end
576 577
    end

578
    def extract_references(arg, type)
579
      ext = Gitlab::ReferenceExtractor.new(project, current_user)
580
      ext.analyze(arg, author: current_user)
581 582 583

      ext.references(type)
    end
M
mhasbini 已提交
584

585 586 587 588 589 590 591
    def context
      {
        issuable: issuable,
        current_user: current_user,
        project: project,
        params: params
      }
M
mhasbini 已提交
592
    end
593 594
  end
end