diff --git a/.gitignore b/.gitignore index f092260..51d2f7b 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ jira-cloud-exporter +TODO \ No newline at end of file diff --git a/collector/collector.go b/collector/collector.go index 1905240..5a55e9b 100644 --- a/collector/collector.go +++ b/collector/collector.go @@ -5,6 +5,7 @@ import ( "fmt" "io/ioutil" "net/http" + "net/url" "strings" "time" @@ -13,10 +14,10 @@ import ( log "github.com/sirupsen/logrus" ) -// JiraCollector initiates the collection of metrics from a JIRA instance -func JiraCollector() *JiraMetrics { - return &JiraMetrics{ - jiraIssues: prometheus.NewDesc(prometheus.BuildFQName("jira", "cloud", "exporter"), +// JiraCollector initiates the collection of Metrics from a JIRA instance +func JiraCollector() *Metrics { + return &Metrics{ + issue: prometheus.NewDesc(prometheus.BuildFQName("jira", "cloud", "issue"), "Shows the number of issues matching the JQL", []string{"status", "project", "key", "assignee"}, nil, ), @@ -24,12 +25,12 @@ func JiraCollector() *JiraMetrics { } // Describe writes all descriptors to the prometheus desc channel. -func (collector *JiraMetrics) Describe(ch chan<- *prometheus.Desc) { - ch <- collector.jiraIssues +func (collector *Metrics) Describe(ch chan<- *prometheus.Desc) { + ch <- collector.issue } //Collect implements required collect function for all prometheus collectors -func (collector *JiraMetrics) Collect(ch chan<- prometheus.Metric) { +func (collector *Metrics) Collect(ch chan<- prometheus.Metric) { collectedIssues, err := fetchJiraIssues() if err != nil { @@ -39,7 +40,7 @@ func (collector *JiraMetrics) Collect(ch chan<- prometheus.Metric) { for _, issue := range collectedIssues.Issues { createdTimestamp := convertToUnixTime(issue.Fields.Created) - ch <- prometheus.MustNewConstMetric(collector.jiraIssues, prometheus.CounterValue, createdTimestamp, issue.Fields.Status.Name, issue.Fields.Project.Name, issue.Key, issue.Fields.Assignee.Name) + ch <- prometheus.MustNewConstMetric(collector.issue, prometheus.CounterValue, createdTimestamp, issue.Fields.Status.Name, issue.Fields.Project.Name, issue.Key, issue.Fields.Assignee.Name) } } @@ -54,64 +55,104 @@ func convertToUnixTime(timestamp string) float64 { return float64(dateTime.Unix()) } -func fetchJiraIssues() (JiraIssues, error) { +func fetchJiraIssues() (jiraIssue, error) { cfgs, err := config.Init() if err != nil { log.Error(err) } - var AllIssues JiraIssues + var AllIssues jiraIssue for _, cfg := range cfgs { - var jiraIssues JiraIssues - - // Confirm the Jira URL begins with the http:// or https:// scheme specification - // Also emit a warning if HTTPS isn't being used - if !strings.HasPrefix(cfg.JiraURL, "http") { - err := fmt.Errorf("The Jira URL: %s does not begin with 'http'", cfg.JiraURL) - return jiraIssues, err - } else if !strings.HasPrefix(cfg.JiraURL, "https://") { - log.Warn("The Jira URL: ", cfg.JiraURL, " is insecure, your API token is being sent in clear text") - } - if len(cfg.JiraUsername) < 6 { - log.Warn("The Jira username has fewer than 6 characters, are you sure it is valid?") - } - if len(cfg.JiraToken) < 10 { - log.Warn("The Jira token has fewer than 10 characters, are you sure it is valid?") - } + var ji jiraIssue - client := http.Client{} - url := fmt.Sprintf("%s/rest/api/2/search?jql=%s", cfg.JiraURL, cfg.JiraJql) - req, err := http.NewRequest(http.MethodGet, url, nil) + err = validateJiraCfg(cfg) if err != nil { - return jiraIssues, err + return ji, err } - req.Header.Set("User-Agent", "jira-cloud-exporter") - req.SetBasicAuth(cfg.JiraUsername, cfg.JiraToken) - log.Info(fmt.Sprintf("Sending request to %s", url)) - res, err := client.Do(req) + url := fmt.Sprintf("%s/rest/api/2/search?jql=%s", cfg.JiraURL, cfg.JiraJql) + resp, err := fetchAPIResults(url, cfg.JiraUsername, cfg.JiraToken) + + err = json.Unmarshal(resp, &ji) if err != nil { - return jiraIssues, err + return ji, err } - body, readErr := ioutil.ReadAll(res.Body) - if readErr != nil { - return jiraIssues, err - } + AllIssues.Issues = append(AllIssues.Issues, ji.Issues...) - jsonError := json.Unmarshal(body, &jiraIssues) - if jsonError != nil { - return jiraIssues, err - } + // Pagination support + if ji.Total > len(AllIssues.Issues) { + var startsAt int + + for { - AllIssues.Issues = append(AllIssues.Issues, jiraIssues.Issues...) + // we use startsAt to track our process through the pagination + // here we set it to the lenghth of the intial capture, + 1 + startsAt = len(AllIssues.Issues) + 1 + url := fmt.Sprintf("%s/rest/api/2/search?jql=%s&startAt=%d", cfg.JiraURL, cfg.JiraJql, startsAt) + resp, err := fetchAPIResults(url, cfg.JiraUsername, cfg.JiraToken) + + err = json.Unmarshal(resp, &ji) + if err != nil { + return ji, err + } + + AllIssues.Issues = append(AllIssues.Issues, ji.Issues...) + + // The API has a funny way of counting, to ensure we get all issues + // We break when no more issues are returned + if len(ji.Issues) == 0 { + log.Debug("No futher issues returned from API") + break + } + + } + } } return AllIssues, nil } -type error interface { - Error() string +func fetchAPIResults(url, user, token string) ([]byte, error) { + + client := http.Client{} + + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return nil, err + } + + req.Header.Set("User-Agent", "jira-cloud-exporter") + req.SetBasicAuth(user, token) + log.Infof(fmt.Sprintf("Sending request to %s", url)) + + resp, err := client.Do(req) + if err != nil { + return nil, err + } + + defer resp.Body.Close() + + body, err := ioutil.ReadAll(resp.Body) + return body, err +} + +func validateJiraCfg(cfg config.Config) error { + + _, err := url.ParseRequestURI(cfg.JiraURL) + if err != nil { + return fmt.Errorf("Error validating URL, please ensure the URL is valid: %v", err) + } + + if !strings.HasPrefix(cfg.JiraURL, "https://") { + return fmt.Errorf("The Jira URL: %s is insecure, your API token is being sent in clear text", cfg.JiraURL) + } + + if cfg.JiraUsername == "" || cfg.JiraToken == "" { + return fmt.Errorf("Check credentials supplied are set and valid") + } + + return nil } diff --git a/collector/structs.go b/collector/structs.go index c8b22e1..d3e34b2 100644 --- a/collector/structs.go +++ b/collector/structs.go @@ -2,34 +2,28 @@ package collector import "github.com/prometheus/client_golang/prometheus" -type JiraMetrics struct { - jiraIssues *prometheus.Desc +// Metrics tracks all the contextual metrics for this exporter +type Metrics struct { + issue *prometheus.Desc } -type JiraIssue struct { - Fields Fields `json:"fields"` - Key string `json:"key"` -} - -type Fields struct { - Assignee Assignee `json:"assignee"` - Project Project `json:"project"` - Status Status `json:"status"` - Created string `json:"created"` -} - -type Assignee struct { - Name string `json:"name"` -} - -type Status struct { - Name string `json:"name"` -} - -type Project struct { - Name string `json:"name"` -} - -type JiraIssues struct { - Issues []JiraIssue `json:"issues"` +type jiraIssue struct { + Issues []struct { + Fields struct { + Assignee struct { + Name string `json:"name"` + } `json:"assignee"` + Created string `json:"created"` + Project struct { + Name string `json:"name"` + } `json:"project"` + Status struct { + Name string `json:"name"` + } `json:"status"` + } `json:"fields"` + Key string `json:"key"` + } `json:"issues"` + MaxResults int `json:"maxResults"` + StartAt int `json:"startAt"` + Total int `json:"total"` }