cantino/huginn

View on GitHub
app/models/agents/jira_agent.rb

Summary

Maintainability
B
4 hrs
Test Coverage
#!/usr/bin/env ruby

require 'cgi'
require 'httparty'
require 'date'

module Agents
  class JiraAgent < Agent
    include WebRequestConcern

    cannot_receive_events!

    description <<~MD
      The Jira Agent subscribes to Jira issue updates.

      - `jira_url` specifies the full URL of the jira installation, including https://
      - `jql` is an optional Jira Query Language-based filter to limit the flow of events. See [JQL Docs](https://confluence.atlassian.com/display/JIRA/Advanced+Searching) for details.#{' '}
      - `username` and `password` are optional, and may need to be specified if your Jira instance is read-protected
      - `timeout` is an optional parameter that specifies how long the request processing may take in minutes.

      The agent does periodic queries and emits the events containing the updated issues in JSON format.

      NOTE: upon the first execution, the agent will fetch everything available by the JQL query. So if it's not desirable, limit the `jql` query by date.
    MD

    event_description <<~MD
      Events are the raw JSON generated by Jira REST API

          {
            "expand": "editmeta,renderedFields,transitions,changelog,operations",
            "id": "80127",
            "self": "https://jira.atlassian.com/rest/api/2/issue/80127",
            "key": "BAM-3512",
            "fields": {
              ...
            }
          }
    MD

    default_schedule "every_10m"
    MAX_EMPTY_REQUESTS = 10

    def default_options
      {
        'username' => '',
        'password' => '',
        'jira_url' => 'https://jira.atlassian.com',
        'jql' => '',
        'expected_update_period_in_days' => '7',
        'timeout' => '1'
      }
    end

    def validate_options
      errors.add(:base,
                 "you need to specify password if user name is set") if options['username'].present? and !options['password'].present?
      errors.add(:base, "you need to specify your jira URL") unless options['jira_url'].present?
      errors.add(:base,
                 "you need to specify the expected update period") unless options['expected_update_period_in_days'].present?
      errors.add(:base, "you need to specify request timeout") unless options['timeout'].present?
    end

    def working?
      event_created_within?(interpolated['expected_update_period_in_days']) && !recent_error_logs?
    end

    def check
      last_run = nil

      current_run = Time.now.utc.iso8601
      last_run = Time.parse(memory[:last_run]) if memory[:last_run]
      issues = get_issues(last_run)

      issues.each do |issue|
        updated = Time.parse(issue['fields']['updated'])

        # this check is more precise than in get_issues()
        # see get_issues() for explanation
        if !last_run or updated > last_run
          create_event payload: issue
        end
      end

      memory[:last_run] = current_run
    end

    private

    def request_url(jql, start_at)
      "#{interpolated[:jira_url]}/rest/api/2/search?jql=#{CGI.escape(jql)}&fields=*all&startAt=#{start_at}"
    end

    def request_options
      ropts = { headers: { "User-Agent" => user_agent } }

      if !interpolated[:username].empty?
        ropts = ropts.merge({
          basic_auth: {
            username: interpolated[:username],
            password: interpolated[:password]
          }
        })
      end

      ropts
    end

    def get(url, options)
      response = HTTParty.get(url, options)

      case response.code
      when 200
        # OK
      when 400
        raise "Jira error: #{response['errorMessages']}"
      when 403
        raise "Authentication failed: Forbidden (403)"
      else
        raise "Request failed: #{response}"
      end

      response
    end

    def get_issues(since)
      startAt = 0
      issues = []

      # JQL doesn't have an ability to specify timezones
      # Because of this we have to fetch issues 24 h
      # earlier and filter out unnecessary ones at a later
      # stage. Fortunately, the 'updated' field has GMT
      # offset
      since -= 24 * 60 * 60 if since

      jql = ""

      if !interpolated[:jql].empty? && since
        jql = "(#{interpolated[:jql]}) and updated >= '#{since.strftime('%Y-%m-%d %H:%M')}'"
      else
        jql = interpolated[:jql] if !interpolated[:jql].empty?
        jql = "updated >= '#{since.strftime('%Y-%m-%d %H:%M')}'" if since
      end

      start_time = Time.now

      request_limit = 0
      loop do
        response = get(request_url(jql, startAt), request_options)

        if response['issues'].length == 0
          request_limit += 1
        end

        if request_limit > MAX_EMPTY_REQUESTS
          raise "There is no progress while fetching issues"
        end

        if Time.now > start_time + interpolated['timeout'].to_i * 60
          raise "Timeout exceeded while fetching issues"
        end

        issues += response['issues']
        startAt += response['issues'].length

        break if startAt >= response['total']
      end

      issues
    end
  end
end