Skip to content

Commit f3ce856

Browse files
committed
Add jira client cli tool
1 parent 9658154 commit f3ce856

File tree

3 files changed

+354
-0
lines changed

3 files changed

+354
-0
lines changed

tools/jira_client/go.mod

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
module github.com/smartcontractkit/chainlink-testing-framework/tools/jiraclient
2+
3+
go 1.23.3
4+
5+
require (
6+
github.com/andygrunwald/go-jira v1.16.0 // indirect
7+
github.com/fatih/structs v1.1.0 // indirect
8+
github.com/golang-jwt/jwt/v4 v4.4.2 // indirect
9+
github.com/google/go-querystring v1.1.0 // indirect
10+
github.com/inconshreveable/mousetrap v1.1.0 // indirect
11+
github.com/pkg/errors v0.9.1 // indirect
12+
github.com/spf13/cobra v1.8.1 // indirect
13+
github.com/spf13/pflag v1.0.5 // indirect
14+
github.com/trivago/tgo v1.0.7 // indirect
15+
)

tools/jira_client/go.sum

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
github.com/andygrunwald/go-jira v1.16.0 h1:PU7C7Fkk5L96JvPc6vDVIrd99vdPnYudHu4ju2c2ikQ=
2+
github.com/andygrunwald/go-jira v1.16.0/go.mod h1:UQH4IBVxIYWbgagc0LF/k9FRs9xjIiQ8hIcC6HfLwFU=
3+
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
4+
github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo=
5+
github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
6+
github.com/golang-jwt/jwt/v4 v4.4.2 h1:rcc4lwaZgFMCZ5jxF9ABolDcIHdBytAFgqFPbSJQAYs=
7+
github.com/golang-jwt/jwt/v4 v4.4.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
8+
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
9+
github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
10+
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
11+
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
12+
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
13+
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
14+
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
15+
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
16+
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
17+
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
18+
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
19+
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
20+
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
21+
github.com/trivago/tgo v1.0.7 h1:uaWH/XIy9aWYWpjm2CU3RpcqZXmX2ysQ9/Go+d9gyrM=
22+
github.com/trivago/tgo v1.0.7/go.mod h1:w4dpD+3tzNIIiIfkWWa85w5/B77tlvdZckQ+6PkFnhc=
23+
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
24+
golang.org/x/sys v0.0.0-20220330033206-e17cdc41300f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
25+
golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
26+
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
27+
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
28+
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

tools/jira_client/main.go

Lines changed: 311 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,311 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"log"
7+
"os"
8+
"strings"
9+
10+
"github.com/andygrunwald/go-jira"
11+
"github.com/spf13/cobra"
12+
)
13+
14+
// Flags for subcommands
15+
var (
16+
projectKey string
17+
issueKey string
18+
summary string
19+
description string
20+
issueType string
21+
commentBody string
22+
transitionName string
23+
resolutionName string
24+
)
25+
26+
// rootCmd is the base command
27+
var rootCmd = &cobra.Command{
28+
Use: "jira-cli",
29+
Short: "A CLI tool to interact with Jira",
30+
Long: `jira-cli is a command line interface tool that uses the go-jira library
31+
to list, create, and update Jira tickets. Jira domain, email, and API key
32+
must be provided as environment variables:
33+
34+
JIRA_DOMAIN, JIRA_EMAIL, JIRA_API_KEY
35+
`,
36+
}
37+
38+
// listCmd lists the tickets in a project
39+
var listCmd = &cobra.Command{
40+
Use: "list",
41+
Short: "List tickets in a project",
42+
Run: func(cmd *cobra.Command, args []string) {
43+
client, err := getJiraClient()
44+
if err != nil {
45+
log.Fatalf("Error creating Jira client: %v", err)
46+
}
47+
48+
if projectKey == "" {
49+
log.Fatal("Project key is required (use --projectKey)")
50+
}
51+
52+
jql := fmt.Sprintf("project = %s ORDER BY created DESC", projectKey)
53+
issues, _, err := client.Issue.SearchWithContext(context.Background(), jql, nil)
54+
if err != nil {
55+
log.Fatalf("Error fetching tickets: %v", err)
56+
}
57+
58+
fmt.Printf("Tickets in project %s:\n", projectKey)
59+
if len(issues) == 0 {
60+
fmt.Println("No tickets found.")
61+
return
62+
}
63+
64+
for _, issue := range issues {
65+
fmt.Printf("- %s: %s\n", issue.Key, issue.Fields.Summary)
66+
}
67+
},
68+
}
69+
70+
// createCmd creates a new ticket in a project
71+
var createCmd = &cobra.Command{
72+
Use: "create",
73+
Short: "Create a new ticket in a project",
74+
Run: func(cmd *cobra.Command, args []string) {
75+
client, err := getJiraClient()
76+
if err != nil {
77+
log.Fatalf("Error creating Jira client: %v", err)
78+
}
79+
80+
if projectKey == "" {
81+
log.Fatal("Project key is required (use --projectKey)")
82+
}
83+
if summary == "" {
84+
log.Fatal("Summary is required (use --summary)")
85+
}
86+
if issueType == "" {
87+
issueType = "Task" // default to "Task" if not provided
88+
}
89+
90+
issue := &jira.Issue{
91+
Fields: &jira.IssueFields{
92+
Project: jira.Project{Key: projectKey},
93+
Summary: summary,
94+
Description: description,
95+
Type: jira.IssueType{
96+
Name: issueType,
97+
},
98+
},
99+
}
100+
101+
newIssue, resp, err := client.Issue.Create(issue)
102+
if err != nil {
103+
log.Fatalf("Error creating ticket: %v\nResponse: %v", err, resp)
104+
}
105+
106+
fmt.Printf("Ticket created! %v\n", newIssue)
107+
},
108+
}
109+
110+
// updateCmd updates an existing ticket
111+
var updateCmd = &cobra.Command{
112+
Use: "update",
113+
Short: "Update an existing ticket",
114+
Run: func(cmd *cobra.Command, args []string) {
115+
client, err := getJiraClient()
116+
if err != nil {
117+
log.Fatalf("Error creating Jira client: %v", err)
118+
}
119+
120+
if issueKey == "" {
121+
log.Fatal("Issue key is required (use --issueKey)")
122+
}
123+
124+
// Fetch the existing issue
125+
issue, resp, err := client.Issue.Get(issueKey, nil)
126+
if err != nil {
127+
log.Fatalf("Error fetching issue %s: %v\nResponse: %v", issueKey, err, resp)
128+
}
129+
130+
// Update only if flags were provided
131+
if summary != "" {
132+
issue.Fields.Summary = summary
133+
}
134+
if description != "" {
135+
issue.Fields.Description = description
136+
}
137+
138+
updatedIssue, resp, err := client.Issue.Update(issue)
139+
if err != nil {
140+
log.Fatalf("Error updating issue %s: %v\nResponse: %v", issueKey, err, resp)
141+
}
142+
143+
fmt.Printf("Ticket updated! Key: %s Summary: %s\n", updatedIssue.Key, updatedIssue.Fields.Summary)
144+
},
145+
}
146+
147+
// commentCmd adds a comment to an existing ticket
148+
var commentCmd = &cobra.Command{
149+
Use: "comment",
150+
Short: "Add a comment to an existing ticket",
151+
Run: func(cmd *cobra.Command, args []string) {
152+
client, err := getJiraClient()
153+
if err != nil {
154+
log.Fatalf("Error creating Jira client: %v", err)
155+
}
156+
157+
if issueKey == "" {
158+
log.Fatal("Issue key is required (use --issueKey)")
159+
}
160+
if commentBody == "" {
161+
log.Fatal("Comment body is required (use --body)")
162+
}
163+
164+
comment := &jira.Comment{
165+
Body: commentBody,
166+
}
167+
168+
newComment, resp, err := client.Issue.AddComment(issueKey, comment)
169+
if err != nil {
170+
log.Fatalf("Error adding comment to issue %s: %v\nResponse: %v", issueKey, err, resp)
171+
}
172+
173+
fmt.Printf("Comment added to %s: %s\n", issueKey, newComment.Body)
174+
},
175+
}
176+
177+
// closeCmd transitions an issue to "Done", "Closed", or any target transitionName
178+
var closeCmd = &cobra.Command{
179+
Use: "close",
180+
Short: "Close (transition) an existing ticket to a given state (e.g. Done)",
181+
Long: `Attempt to transition the specified issue to a given state by name.
182+
By default, tries to move the ticket to "Done" unless --transitionName is set.`,
183+
Run: func(cmd *cobra.Command, args []string) {
184+
client, err := getJiraClient()
185+
if err != nil {
186+
log.Fatalf("Error creating Jira client: %v", err)
187+
}
188+
189+
if issueKey == "" {
190+
log.Fatal("Issue key is required (use --issueKey)")
191+
}
192+
if transitionName == "" {
193+
transitionName = "Done" // default transition name
194+
}
195+
if resolutionName == "" {
196+
resolutionName = "Done" // default resolution if none provided
197+
}
198+
199+
// 1. Get available transitions
200+
transitions, resp, err := client.Issue.GetTransitions(issueKey)
201+
if err != nil {
202+
log.Fatalf("Error getting transitions for %s: %v\nResponse: %v", issueKey, err, resp)
203+
}
204+
205+
// 2. Find the desired transition
206+
var desiredID string
207+
for _, t := range transitions {
208+
if strings.EqualFold(t.Name, transitionName) {
209+
desiredID = t.ID
210+
break
211+
}
212+
}
213+
214+
if desiredID == "" {
215+
log.Fatalf("Transition '%s' not found for issue %s. Available transitions: %v",
216+
transitionName, issueKey, getTransitionNames(transitions))
217+
}
218+
219+
// 3. Build transition payload with resolution
220+
transitionPayload := jira.CreateTransitionPayload{
221+
Transition: jira.TransitionPayload{
222+
ID: desiredID,
223+
},
224+
Fields: jira.TransitionPayloadFields{
225+
Resolution: &jira.Resolution{
226+
Name: resolutionName,
227+
},
228+
},
229+
}
230+
231+
// 4. Execute the transition with the payload
232+
if _, err := client.Issue.DoTransitionWithPayload(issueKey, transitionPayload); err != nil {
233+
log.Fatalf("Error transitioning issue %s to '%s' with resolution '%s': %v",
234+
issueKey, transitionName, resolutionName, err)
235+
}
236+
237+
fmt.Printf("Issue %s transitioned to '%s' (resolution: %s)\n", issueKey, transitionName, resolutionName)
238+
},
239+
}
240+
241+
func init() {
242+
// Add subcommands to the root command
243+
rootCmd.AddCommand(listCmd)
244+
rootCmd.AddCommand(createCmd)
245+
rootCmd.AddCommand(updateCmd)
246+
rootCmd.AddCommand(commentCmd)
247+
rootCmd.AddCommand(closeCmd)
248+
249+
// Flags for listing tickets
250+
listCmd.Flags().StringVar(&projectKey, "projectKey", "", "Project key (e.g. TEST)")
251+
252+
// Flags for creating tickets
253+
createCmd.Flags().StringVar(&projectKey, "projectKey", "", "Project key (e.g. TEST)")
254+
createCmd.Flags().StringVar(&summary, "summary", "", "Issue summary")
255+
createCmd.Flags().StringVar(&description, "description", "", "Issue description")
256+
createCmd.Flags().StringVar(&issueType, "issueType", "Task", "Issue type (e.g. Task, Bug, Story)")
257+
258+
// Flags for updating tickets
259+
updateCmd.Flags().StringVar(&issueKey, "issueKey", "", "Issue key to update (e.g. TEST-123)")
260+
updateCmd.Flags().StringVar(&summary, "summary", "", "New summary for the issue")
261+
updateCmd.Flags().StringVar(&description, "description", "", "New description for the issue")
262+
263+
// Flags for commenting
264+
commentCmd.Flags().StringVar(&issueKey, "issueKey", "", "Issue key to comment on (e.g. TEST-123)")
265+
commentCmd.Flags().StringVar(&commentBody, "body", "", "Comment body")
266+
267+
// Flags for closing tickets
268+
closeCmd.Flags().StringVar(&issueKey, "issueKey", "", "Issue key to close (e.g. TEST-123)")
269+
closeCmd.Flags().StringVar(&transitionName, "transitionName", "", "Name of the desired transition (default: Done)")
270+
}
271+
272+
// main executes the root command
273+
func main() {
274+
if err := rootCmd.Execute(); err != nil {
275+
fmt.Println(err)
276+
os.Exit(1)
277+
}
278+
}
279+
280+
// getJiraClient constructs the Jira client using environment variables
281+
func getJiraClient() (*jira.Client, error) {
282+
domain := os.Getenv("JIRA_DOMAIN")
283+
if domain == "" {
284+
return nil, fmt.Errorf("JIRA_DOMAIN environment variable is not set")
285+
}
286+
287+
email := os.Getenv("JIRA_EMAIL")
288+
if email == "" {
289+
return nil, fmt.Errorf("JIRA_EMAIL environment variable is not set")
290+
}
291+
292+
apiKey := os.Getenv("JIRA_API_KEY")
293+
if apiKey == "" {
294+
return nil, fmt.Errorf("JIRA_API_KEY environment variable is not set")
295+
}
296+
297+
tp := jira.BasicAuthTransport{
298+
Username: email,
299+
Password: apiKey,
300+
}
301+
return jira.NewClient(tp.Client(), fmt.Sprintf("https://%s", domain))
302+
}
303+
304+
// getTransitionNames is a helper to format transition names for error messages
305+
func getTransitionNames(transitions []jira.Transition) []string {
306+
var names []string
307+
for _, t := range transitions {
308+
names = append(names, t.Name)
309+
}
310+
return names
311+
}

0 commit comments

Comments
 (0)