#!/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