Skip to content

Commit

Permalink
Merge pull request #1 from platform/feature/OP-1278-update-existing-u…
Browse files Browse the repository at this point in the history
…pdate-tickets

[OP-1278] Update existing software update jira tickets
  • Loading branch information
Nicolas Mohr authored and GitHub Enterprise committed Aug 2, 2022
2 parents dbb13c4 + 0ded5cd commit 50ffa91
Show file tree
Hide file tree
Showing 3 changed files with 243 additions and 55 deletions.
22 changes: 22 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -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
20 changes: 14 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
1. `go run main.go` / `./jelease`
2. Direct newreleases.io webhooks to the `host:port/webhook` route.
256 changes: 207 additions & 49 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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()
}

0 comments on commit 50ffa91

Please sign in to comment.