diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7500c9a --- /dev/null +++ b/.gitignore @@ -0,0 +1,22 @@ +jelease +.env + +# from https://github.com/github/gitignore/blob/main/Go.gitignore +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Dependency directories (remove the comment below to include it) +# vendor/ + +# Go workspace file +go.work \ No newline at end of file diff --git a/README.md b/README.md index a5c6243..6a53c29 100644 --- a/README.md +++ b/README.md @@ -6,12 +6,20 @@ Automatically create Jira tickets when a newreleases.io release is detected usin The application requires the following environment variables to be set: - `JELEASE_PORT`: The port the application is expecting traffic on -- `JELEASE_ADDLABELS`: Add additional labels to the created jira ticket -- `JELEASE_JIRAURL`: The URL of your Jira instance -- `JELEASE_JIRAUSER`: Jira username to authenticate API requests -- `JELEASE_JIRATOKEN`: Jira API token, can also be a password in self-hosted instances -- `JELEASE_JIRAPROJECT`: Jira Project key the tickets will be created in +- `JELEASE_JIRA_URL`: The URL of your Jira instance +- `JELEASE_JIRA_USER`: Jira username to authenticate API requests +- `JELEASE_JIRA_TOKEN`: Jira API token, can also be a password in self-hosted instances +- `JELEASE_PROJECT`: Jira Project key the tickets will be created in +- `JELEASE_ADD_LABELS`: Add additional labels to the created jira ticket +- `JELEASE_DEFAULT_STATUS`: The status the created tickets are supposed to have + +They can also be specified using a `.env` file in the application directory. + +## Building the application + +`go build` ## Usage -Direct newreleases.io webhooks to the `host:port/webhook` route. \ No newline at end of file +1. `go run main.go` / `./jelease` +2. Direct newreleases.io webhooks to the `host:port/webhook` route. \ No newline at end of file diff --git a/main.go b/main.go index ecf5482..e314bce 100644 --- a/main.go +++ b/main.go @@ -5,10 +5,11 @@ import ( "errors" "fmt" "io" - "io/ioutil" "log" "net/http" "os" + "strings" + "time" jira "github.com/andygrunwald/go-jira" "github.com/joho/godotenv" @@ -18,111 +19,268 @@ import ( var ( jiraClient *jira.Client config Config + logger *log.Logger ) -// Configuration read from environment or .env file +// Config contains configuration values from environment and .env file. +// Environment takes precedence over the .env file in case of conflicts. type Config struct { - // Consider: add split_words tag for nicer readability of env vars - Port string `default:"8080"` - JiraUrl string `required:"true"` - JiraUser string `required:"true"` - JiraToken string `required:"true"` - JiraProject string `required:"true"` - AddLabels []string + Port string `envconfig:"PORT" default:"8080"` + JiraUrl string `envconfig:"JIRA_URL" required:"true"` + JiraUser string `envconfig:"JIRA_USER" required:"true"` + JiraToken string `envconfig:"JIRA_TOKEN" required:"true"` + Project string `envconfig:"PROJECT" required:"true"` + DefaultStatus string `envconfig:"DEFAULT_STATUS" required:"true"` + AddLabels []string `envconfig:"ADD_LABELS"` } -// A new release object unmarshaled from the newreleases.io webhook. -// Some fields omitted for simplicity, check out the documentation at https://newreleases.io/webhooks -type NewRelease struct { +// Release object unmarshaled from the newreleases.io webhook. +// Some fields omitted for simplicity, refer to the documentation at https://newreleases.io/webhooks +type Release struct { Provider string `json:"provider"` Project string `json:"project"` Version string `json:"version"` } -func ReleaseToJiraIssue(newRelease NewRelease) jira.Issue { - labels := append(config.AddLabels, newRelease.Project) +// Generates a Textual summary for the release, intended to be used as the Jira issue summary +func (r Release) IssueSummary() string { + return fmt.Sprintf("Update %v to version %v", r.Project, r.Version) +} + +func (r Release) JiraIssue() jira.Issue { + labels := append(config.AddLabels, r.Project) return jira.Issue{ Fields: &jira.IssueFields{ - Description: "Update issue generated by newreleases.io", + Description: "Update issue generated by https://github.2rioffice.com/platform/jelease using newreleases.io.", Project: jira.Project{ - Key: config.JiraProject, + Key: config.Project, }, Type: jira.IssueType{ Name: "Task", }, - // Status: , + Status: &jira.Status{ + Name: config.DefaultStatus, + }, Labels: labels, - Summary: fmt.Sprintf("Update %v to version %v", newRelease.Project, newRelease.Version), + Summary: r.IssueSummary(), }, } } -func getRoot(w http.ResponseWriter, r *http.Request) { - fmt.Printf("got / request\n") - io.WriteString(w, "Pong!\n") +// handleGetRoot handles to GET requests for a basic reachability check +func handleGetRoot(w http.ResponseWriter, r *http.Request) { + logger.Println("Received health check request") + io.WriteString(w, "Ok") } -func postWebhook(w http.ResponseWriter, r *http.Request) { +// handlePostWebhook handles newreleases.io webhook post requests +func handlePostWebhook(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) + logger.Printf("Rejected request because: %v %v. Attempted method: %v", + http.StatusMethodNotAllowed, http.StatusText(http.StatusMethodNotAllowed), r.Method) + return } // parse newreleases.io webhook decoder := json.NewDecoder(r.Body) - var newRelease NewRelease - err := decoder.Decode(&newRelease) + var release Release + err := decoder.Decode(&release) if err != nil { http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) - fmt.Fprintf(os.Stderr, "Couldn't decode body to json: %v\n error: %v\n", r.Body, err) + logger.Printf("failed to decode request body to json with error: %v\n", err) + return } - fmt.Println(newRelease) - // look for existing releases - - // create jira ticket - i := ReleaseToJiraIssue(newRelease) - issue, response, err := jiraClient.Issue.Create(&i) + // look for existing update tickets + existingIssuesQuery := fmt.Sprintf("status = %q and labels = %q", config.DefaultStatus, release.Project) + existingIssues, resp, err := jiraClient.Issue.Search(existingIssuesQuery, &jira.SearchOptions{}) if err != nil { - fmt.Printf("%+v\n", err) - body, _ := ioutil.ReadAll(response.Body) - fmt.Printf("Error response from Jira: %+v\n", string(body)) + body, readErr := io.ReadAll(resp.Body) + errCtx := errors.New("error response from Jira when searching previous issues") + if readErr != nil { + logger.Println(fmt.Errorf("%v: %w. Failed to decode response body: %v", errCtx, err, readErr).Error()) + } else { + logger.Println(fmt.Errorf("%v: %w. Response body: %v", errCtx, err, string(body)).Error()) + } http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + + if len(existingIssues) == 0 { + // no previous issues, create new jira issue + i := release.JiraIssue() + newIssue, response, err := jiraClient.Issue.Create(&i) + if err != nil { + body, readErr := io.ReadAll(response.Body) + errCtx := errors.New("error response from Jira when creating issue") + if readErr != nil { + logger.Printf("%v: %v. Failed to decode response body: %v", errCtx, err, readErr) + } else { + logger.Printf("%v: %v. Response body: %v", errCtx, err, string(body)) + } + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + } + logger.Printf("Created issue %v\n", newIssue.ID) + return + } + + // in case of duplicate issues, update the oldest (probably original) one, ignore rest as duplicates + var oldestExistingIssue jira.Issue + var duplicateIssueKeys []string + for i, existingIssue := range existingIssues { + if i == 0 { + oldestExistingIssue = existingIssue + continue + } + tCurrent := time.Time(existingIssue.Fields.Created) + tOldest := time.Time(oldestExistingIssue.Fields.Created) + if tCurrent.Before(tOldest) { + duplicateIssueKeys = append(duplicateIssueKeys, oldestExistingIssue.Key) + oldestExistingIssue = existingIssue + } else { + duplicateIssueKeys = append(duplicateIssueKeys, existingIssue.Key) + } + } + if len(duplicateIssueKeys) > 0 { + logger.Printf("Ignoring the following possible duplicate issues in favor of older issue %v: %v", oldestExistingIssue.Key, + strings.Join(duplicateIssueKeys, ", ")) + } + // This seems hacky, but is taken from the official examples + // https://github.com/andygrunwald/go-jira/blob/47d27a76e84da43f6e27e1cd0f930e6763dc79d7/examples/addlabel/main.go + // There is also a jiraClient.Issue.Update() method, but it panics and does not provide a usage example + type summaryUpdate struct { + Set string `json:"set" structs:"set"` + } + type issueUpdate struct { + Summary []summaryUpdate `json:"summary" structs:"summary"` } - fmt.Printf("Created issue %v\n", issue.ID) + previousSummary := oldestExistingIssue.Fields.Summary + updates := map[string]any{ + "update": issueUpdate{ + Summary: []summaryUpdate{ + {Set: release.IssueSummary()}, + }, + }, + } + resp, err = jiraClient.Issue.UpdateIssue(oldestExistingIssue.ID, updates) + if err != nil { + body, readErr := io.ReadAll(resp.Body) + errCtx := errors.New("error response from Jira when updating issue") + if readErr != nil { + logger.Printf("%v: %v. Failed to decode response body: %v", errCtx, err, readErr) + } else { + logger.Printf("%v: %v. Response body: %v", errCtx, err, string(body)) + } + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + logger.Printf("Updated issue summary from %q to %q", previousSummary, release.IssueSummary()) } -func main() { - // configuration setup +func configSetup() error { err := godotenv.Load() if err != nil { - fmt.Println("No .env file found.") + logger.Println("No .env file found.") } err = envconfig.Process("jelease", &config) if err != nil { - log.Fatal(err.Error()) + return err } - fmt.Printf("Jira URL: %v\n", config.JiraUrl) + logger.Printf("Jira URL: %v\n", config.JiraUrl) tp := jira.BasicAuthTransport{ Username: config.JiraUser, Password: config.JiraToken, } - jiraClient, err = jira.NewClient(tp.Client(), config.JiraUrl) if err != nil { - panic(err) + return fmt.Errorf("failed to create jira client: %w", err) + } + return nil +} + +func projectExists() error { + allProjects, response, err := jiraClient.Project.GetList() + if err != nil { + body, readErr := io.ReadAll(response.Body) + errCtx := errors.New("error response from Jira when retrieving project list") + if readErr != nil { + return fmt.Errorf("%v: %w. Failed to decode response body: %v", errCtx, err, readErr) + } + return fmt.Errorf("%v: %w. Response body: %v", errCtx, err, string(body)) } + var projectExists bool + for _, project := range *allProjects { + if project.Key == config.Project { + projectExists = true + break + } + } + if !projectExists { + return fmt.Errorf("project %v does not exist on your Jira server", config.Project) + } + return nil +} + +func statusExists() error { + allStatuses, response, err := jiraClient.Status.GetAllStatuses() + if err != nil { + body, readErr := io.ReadAll(response.Body) + errCtx := errors.New("error response from Jira when retrieving status list: %+v") + if readErr != nil { + return fmt.Errorf("%v: %w. Failed to decode response body: %v", errCtx, err, readErr) + } + return fmt.Errorf("%v: %w. Response body: %v", errCtx, err, string(body)) + } + var statusExists bool + for _, status := range allStatuses { + if status.Name == config.DefaultStatus { + statusExists = true + break + } + } + if !statusExists { + return fmt.Errorf("status %v does not exist on your Jira server", config.DefaultStatus) + } + return nil +} - // http serversetup - http.HandleFunc("/webhook", postWebhook) - http.HandleFunc("/", getRoot) - fmt.Printf("Listening on port %v\n", config.Port) - err = http.ListenAndServe(fmt.Sprintf(":%v", config.Port), nil) +func serveHTTP() error { + http.HandleFunc("/webhook", handlePostWebhook) + http.HandleFunc("/", handleGetRoot) + logger.Printf("Listening on port %v\n", config.Port) + return http.ListenAndServe(fmt.Sprintf(":%v", config.Port), nil) +} + +func init() { + logger = log.New(os.Stderr, "", log.LstdFlags|log.Lshortfile) +} + +func main() { + err := run() if errors.Is(err, http.ErrServerClosed) { - fmt.Printf("server closed\n") + logger.Println("server closed") } else if err != nil { - fmt.Printf("error starting server: %s\n", err) + logger.Println(err.Error()) os.Exit(1) } } + +func run() error { + + err := configSetup() + if err != nil { + return fmt.Errorf("error in config setup: %w", err) + } + err = projectExists() + if err != nil { + return fmt.Errorf("error in check if configured project exists: %w", err) + } + err = statusExists() + if err != nil { + return fmt.Errorf("error in check if configured default status exists: %w", err) + } + return serveHTTP() +}