From 18b7b49adb8748d64a4183e93903b717f428e031 Mon Sep 17 00:00:00 2001 From: Tom Proctor Date: Wed, 19 Aug 2020 11:00:51 +0100 Subject: [PATCH] Initial commit --- LICENSE | 21 ++++++++++ README.md | 67 +++++++++++++++++++++++++++++ action.yml | 35 ++++++++++++++++ go.mod | 8 ++++ go.sum | 17 ++++++++ main.go | 116 +++++++++++++++++++++++++++++++++++++++++++++++++++ main_test.go | 18 ++++++++ 7 files changed, 282 insertions(+) create mode 100644 LICENSE create mode 100644 README.md create mode 100644 action.yml create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 main_test.go diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5314dfe --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 Tom Proctor + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..6a5c760 --- /dev/null +++ b/README.md @@ -0,0 +1,67 @@ +# gh-action-jira-create + +Use [GitHub actions](https://docs.github.com/en/actions) to create a Jira ticket with customisable fields. + +## Authentication + +To provide a URL and credentials you can use the [`gajira-login`](https://github.com/atlassian/gajira-login) action, which will write a config file this action can read. +Alternatively, you can set some environment variables: + +- `JIRA_BASE_URL` - e.g. `https://my-org.atlassian.net`. The URL for your Jira instance. +- `JIRA_API_TOKEN` - e.g. `iaJGSyaXqn95kqYvq3rcEGu884TCbMkU`. An access token. +- `JIRA_USER_EMAIL` - e.g. `user@example.com`. The email address for the access token. + +## Inputs + +- `project` - The project key to create the issue in, e.g. `FOO` +- `issuetype` - The issue type for the ticket, e.g. `Bug` +- `summary` - The title of the issue, e.g. `A summary` +- `description` - The body of the issue, e.g. `A description of the issue` +- `extraFields` - A JSON map as a string, specifying any additional fields to set in the create issue payload. See the [Jira REST API](https://developer.atlassian.com/cloud/jira/platform/rest/v3/api-group-issues/#api-rest-api-3-issue-post) for more details of the available fields, e.g. `'{"parent": {"key": "FOO-23"}, "labels": ["github", "bug"], "customfield_10071": "from-github-action"}'` + +## Outputs + +- `issue` - The key of the issue created, e.g. TEST-23 + +## Examples + +Using `atlassian/gajira-login` and [GitHub secrets](https://docs.github.com/en/actions/configuring-and-managing-workflows/creating-and-storing-encrypted-secrets) for authentication: + +```yaml +- name: Login + uses: atlassian/gajira-login@v2.0.0 + env: + JIRA_BASE_URL: ${{ secrets.JIRA_BASE_URL }} + JIRA_USER_EMAIL: ${{ secrets.JIRA_USER_EMAIL }} + JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }} + +- name: Create + id: create + uses: tomhjp/gh-action-jira-create@v0.1.0 + with: + project: FOO + issuetype: "Bug" + summary: "The summary" + description: "The description" + +- name: Log + run: echo "Created issue ${{ steps.create.outputs.issue }}" +``` + +Using environment variables for authentication, and the `github` context to populate some additional fields: + +```yaml +- name: Create + id: create + uses: tomhjp/gh-action-jira-create@v0.1.0 + with: + project: FOO + issuetype: "Bug" + summary: "${{ github.event.issue.title }} #${{ github.event.issue.number }}" + description: "${{ github.event.issue.body }}\n\nCreated from GitHub Action" + extraFields: '{"fixVersions": [{"name": "TBD"}], "customfield_20000": "product", "customfield_40000": "${{ github.event.issue.html_url }}"}' + env: + JIRA_BASE_URL: ${{ secrets.JIRA_BASE_URL }} + JIRA_USER_EMAIL: ${{ secrets.JIRA_USER_EMAIL }} + JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }} +``` diff --git a/action.yml b/action.yml new file mode 100644 index 0000000..a4bc2df --- /dev/null +++ b/action.yml @@ -0,0 +1,35 @@ +name: Jira Search +description: Find a specific Jira issue +inputs: + project: + description: The project key to create the issue in + required: true + issuetype: + description: The issue type for the ticket + required: true + summary: + description: The title of the issue + required: true + description: + description: The body of the issue + required: true + extraFields: + description: A JSON map specifying any additional fields to set in the create issue payload + required: true + default: '{}' +outputs: + issue: + description: Key of the issue created + value: ${{ steps.create.outputs.key }} +runs: + using: 'composite' + steps: + - run: cd ${GITHUB_ACTION_PATH} && go run main.go + id: create + shell: bash + env: + INPUT_PROJECT: ${{ inputs.project }} + INPUT_ISSUE_TYPE: ${{ inputs.issuetype }} + INPUT_SUMMARY: ${{ inputs.summary }} + INPUT_DESCRIPTION: ${{ inputs.description }} + INPUT_EXTRA_FIELDS: ${{ inputs.extraFields }} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..5cfe949 --- /dev/null +++ b/go.mod @@ -0,0 +1,8 @@ +module github.com/tomhjp/gh-action-jira-create + +go 1.14 + +require ( + github.com/stretchr/testify v1.6.1 + github.com/tomhjp/gh-action-jira v0.1.0 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..dc6d1c6 --- /dev/null +++ b/go.sum @@ -0,0 +1,17 @@ +github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= +github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/tomhjp/gh-action-jira v0.1.0 h1:7/0uQCxCT0mQ5cYGaAV0A3bGo/55jyhHaDNLvvl1qF0= +github.com/tomhjp/gh-action-jira v0.1.0/go.mod h1:ToAaW7uKSN8UP2aztAkiMl2ShRtn0n7vRkN1visxwFc= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go new file mode 100644 index 0000000..85762ea --- /dev/null +++ b/main.go @@ -0,0 +1,116 @@ +package main + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "log" + "net/url" + "os" + + "github.com/tomhjp/gh-action-jira/config" + "github.com/tomhjp/gh-action-jira/jira" +) + +func main() { + err := create() + if err != nil { + log.Fatal(err) + } +} + +func create() error { + project := os.Getenv("INPUT_PROJECT") + if project == "" { + return errors.New("no project provided as input") + } + issueType := os.Getenv("INPUT_ISSUE_TYPE") + summary := os.Getenv("INPUT_SUMMARY") + description := os.Getenv("INPUT_DESCRIPTION") + extraFieldsString := os.Getenv("INPUT_EXTRA_FIELDS") + extraFields := map[string]interface{}{} + err := json.Unmarshal([]byte(extraFieldsString), &extraFields) + if err != nil { + return fmt.Errorf("failed to deserialise extraFields: %s", err) + } + + config, err := config.ReadConfig() + if err != nil { + return err + } + + key, err := createIssue(config, project, issueType, summary, description, extraFields) + if err != nil { + return err + } + + fmt.Printf("Created issue %s\n", key) + + // Special format log line to set output for the action. + // See https://docs.github.com/en/actions/creating-actions/metadata-syntax-for-github-actions#outputs-for-composite-run-steps-actions. + fmt.Printf("::set-output name=key::%s\n", key) + + return nil +} + +type createIssuePayload struct { + Fields map[string]interface{} `json:"fields"` +} + +type createIssueResponse struct { + Key string `json:"key"` +} + +func createIssue(config config.JiraConfig, project, issueType, summary, description string, extraFields map[string]interface{}) (string, error) { + payload := constructPayload(project, issueType, summary, description, extraFields) + reqBody, err := json.Marshal(payload) + if err != nil { + return "", err + } + + // Use the REST API v2 because it has a much simpler schema for description. + respBody, err := jira.DoRequest(config, "POST", "/rest/api/2/issue", url.Values{}, bytes.NewReader(reqBody)) + if err != nil { + indentedBody, marshalErr := json.MarshalIndent(payload, "", " ") + if marshalErr != nil { + // We made a best effort, oh well, just print it ugly. + indentedBody = reqBody + } + fmt.Println("Request body:") + fmt.Printf("%s\n", string(indentedBody)) + return "", err + } + + var response createIssueResponse + err = json.Unmarshal(respBody, &response) + if err != nil { + return "", err + } + return response.Key, nil +} + +// An abomination of type unsafety to allow us to handle arbitrary JSON values for extraFields +func constructPayload(project, issueType, summary, description string, extraFields map[string]interface{}) createIssuePayload { + payload := createIssuePayload{ + Fields: map[string]interface{}{ + "project": struct { + Key string `json:"key"` + }{ + project, + }, + "issuetype": struct { + Name string `json:"name"` + }{ + issueType, + }, + "summary": summary, + "description": description, + }, + } + for key, value := range extraFields { + payload.Fields[key] = value + } + + return payload +} diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..d654e50 --- /dev/null +++ b/main_test.go @@ -0,0 +1,18 @@ +package main + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestConstructPayload(t *testing.T) { + const expectedJSON = `{"fields":{"custom_field":[{"name":"foo"}],"description":"The description","foo":"bar","issuetype":{"name":"Bug"},"project":{"key":"FOO"},"summary":"The summary"}}` + reqBody, err := json.Marshal(constructPayload("FOO", "Bug", "The summary", "The description", map[string]interface{}{ + "foo": "bar", + "custom_field": []map[string]string{{"name": "foo"}}, + })) + require.NoError(t, err) + require.Equal(t, expectedJSON, string(reqBody)) +}