From d5f238c8e35c4305e620c5ec57b88f26001cc0ba Mon Sep 17 00:00:00 2001 From: Tomasz Janiszewski Date: Tue, 17 Feb 2026 16:12:50 +0100 Subject: [PATCH 1/5] feat: add Jira Cloud support with go-atlassian v2.3.0 Migrate from andygrunwald/go-jira to ctreminiom/go-atlassian v2.3.0 to support Jira Cloud instances that require the new /rest/api/3/search/jql endpoint. The old /rest/api/[2|3]/search endpoints were deprecated and removed from Jira Cloud (effective May 1, 2025). Key changes: - Replace go-jira with go-atlassian v2.3.0 - Use SearchJQL() method for new /search/jql endpoint - Support Basic Auth with email + API token (required for Jira Cloud) - Use Atlassian Document Format (ADF) for issue descriptions and comments - Request summary field explicitly in search to fix nil field issue Fixes issue search and comment creation for Jira Cloud instances. Related: https://github.com/ctreminiom/go-atlassian/issues/345 Co-Authored-By: Claude Sonnet 4.5 --- cmd/junit2jira/main.go | 138 ++++++++++++++++++++++++++++------------- go.mod | 7 ++- go.sum | 19 ++++-- 3 files changed, 115 insertions(+), 49 deletions(-) diff --git a/cmd/junit2jira/main.go b/cmd/junit2jira/main.go index d3f7eff..e8c67e9 100644 --- a/cmd/junit2jira/main.go +++ b/cmd/junit2jira/main.go @@ -2,6 +2,7 @@ package main import ( "bytes" + "context" _ "embed" "encoding/csv" "encoding/json" @@ -15,15 +16,14 @@ import ( "time" "unicode" - "github.com/andygrunwald/go-jira" + jira "github.com/ctreminiom/go-atlassian/v2/jira/v3" + "github.com/ctreminiom/go-atlassian/v2/pkg/infra/models" "github.com/carlmjohnson/versioninfo" "github.com/hashicorp/go-multierror" - "github.com/hashicorp/go-retryablehttp" "github.com/joshdk/go-junit" "github.com/pkg/errors" log "github.com/sirupsen/logrus" "github.com/slack-go/slack" - "github.com/stackrox/junit2jira/pkg/logger" "github.com/stackrox/junit2jira/pkg/testcase" ) @@ -91,25 +91,33 @@ type junit2jira struct { } type testIssue struct { - issue *jira.Issue + issue *models.IssueScheme newJIRA bool testCase j2jTestCase } func run(p params) error { - retryClient := retryablehttp.NewClient() - retryClient.Logger = logger.NewLeveled() - transport := retryClient.StandardClient().Transport - tp := jira.PATAuthTransport{ - Token: os.Getenv("JIRA_TOKEN"), - Transport: transport, + // Check for username (email) for Basic Auth + jiraUser := os.Getenv("JIRA_USER") + jiraToken := os.Getenv("JIRA_TOKEN") + if jiraToken == "" { + jiraToken = os.Getenv("JIRA_PASSWORD") // backward compatibility } - jiraClient, err := jira.NewClient(tp.Client(), p.jiraUrl.String()) + if jiraUser == "" || jiraToken == "" { + log.Fatal("JIRA_USER (email) and JIRA_TOKEN are required for Jira Cloud authentication") + } + + // Create Jira client using go-atlassian library + jiraClient, err := jira.New(nil, p.jiraUrl.String()) if err != nil { return errors.Wrapf(err, "could not create client for %s", p.jiraUrl) } + // Set Basic Auth with email and API token + jiraClient.Auth.SetBasicAuth(jiraUser, jiraToken) + log.Info("Using Basic Auth (email + API token)") + j := &junit2jira{ params: p, jiraClient: jiraClient, @@ -140,7 +148,7 @@ func run(p params) error { return errors.Wrap(err, "could not convert to slack") } - jiraIssues := make([]*jira.Issue, 0, len(issues)) + jiraIssues := make([]*models.IssueScheme, 0, len(issues)) for _, i := range issues { jiraIssues = append(jiraIssues, i.issue) } @@ -212,7 +220,7 @@ func (j junit2jira) createSlackMessage(tc []*testIssue) error { return nil } -func (j junit2jira) createHtml(issues []*jira.Issue) error { +func (j junit2jira) createHtml(issues []*models.IssueScheme) error { if j.htmlOutput == "" || len(issues) == 0 { return nil } @@ -229,11 +237,11 @@ func (j junit2jira) createHtml(issues []*jira.Issue) error { } type htmlData struct { - Issues []*jira.Issue + Issues []*models.IssueScheme JiraUrl *url.URL } -func (j junit2jira) renderHtml(issues []*jira.Issue, out io.Writer) error { +func (j junit2jira) renderHtml(issues []*models.IssueScheme, out io.Writer) error { t, err := template.New(j.htmlOutput).Parse(htmlOutputTemplate) if err != nil { return fmt.Errorf("could parse template: %w", err) @@ -279,7 +287,7 @@ func (j junit2jira) createIssuesOrComments(failedTests []j2jTestCase) ([]*testIs return issues, result } -func (j junit2jira) linkIssues(issues []*jira.Issue) error { +func (j junit2jira) linkIssues(issues []*models.IssueScheme) error { const linkType = "Related" // link type may vay between jira versions and configurations var result error @@ -291,11 +299,19 @@ func (j junit2jira) linkIssues(issues []*jira.Issue) error { continue } - _, err := j.jiraClient.Issue.AddLink(&jira.IssueLink{ - Type: jira.IssueLinkType{Name: linkType}, - OutwardIssue: &jira.Issue{Key: issue.Key}, - InwardIssue: &jira.Issue{Key: issues[y].Key}, - }) + payload := &models.LinkPayloadSchemeV3{ + Type: &models.LinkTypeScheme{ + Name: linkType, + }, + InwardIssue: &models.LinkedIssueScheme{ + Key: issues[y].Key, + }, + OutwardIssue: &models.LinkedIssueScheme{ + Key: issue.Key, + }, + } + + _, err := j.jiraClient.Issue.Link.Create(context.TODO(), payload) if err != nil { result = multierror.Append(result, err) continue @@ -317,13 +333,20 @@ func (j junit2jira) createIssueOrComment(tc j2jTestCase) (*testIssue, error) { } const NA = "?" logEntry(NA, summary).Debug("Searching for issue") - search, response, err := j.jiraClient.Issue.Search(fmt.Sprintf(jql, j.jiraProject, summary), nil) + searchResult, response, err := j.jiraClient.Issue.Search.SearchJQL( + context.TODO(), + fmt.Sprintf(jql, j.jiraProject, summary), + []string{"summary"}, // fields - request summary field + nil, // expand + 50, // maxResults + "", // nextPageToken (empty for first page) + ) if err != nil { logError(err, response) return nil, fmt.Errorf("could not search: %w", err) } - issue := findMatchingIssue(search, summary) + issue := findMatchingIssue(searchResult.Issues, summary) issueWithTestCase := testIssue{ issue: issue, testCase: tc, @@ -336,7 +359,7 @@ func (j junit2jira) createIssueOrComment(tc j2jTestCase) (*testIssue, error) { return nil, nil } issue = newIssue(j.jiraProject, summary, description) - create, response, err := j.jiraClient.Issue.Create(issue) + create, response, err := j.jiraClient.Issue.Create(context.TODO(), issue, nil) if err != nil { logError(err, response) return nil, fmt.Errorf("could not create issue %s: %w", summary, err) @@ -351,8 +374,23 @@ func (j junit2jira) createIssueOrComment(tc j2jTestCase) (*testIssue, error) { return &issueWithTestCase, nil } - comment := jira.Comment{ - Body: description, + // Create ADF (Atlassian Document Format) comment with plain text + comment := &models.CommentPayloadScheme{ + Body: &models.CommentNodeScheme{ + Version: 1, + Type: "doc", + Content: []*models.CommentNodeScheme{ + { + Type: "paragraph", + Content: []*models.CommentNodeScheme{ + { + Type: "text", + Text: description, + }, + }, + }, + }, + }, } logEntry(issue.Key, issue.Fields.Summary).Info("Found issue. Creating a comment...") @@ -362,7 +400,7 @@ func (j junit2jira) createIssueOrComment(tc j2jTestCase) (*testIssue, error) { return &issueWithTestCase, nil } - addComment, response, err := j.jiraClient.Issue.AddComment(issue.ID, &comment) + addComment, response, err := j.jiraClient.Issue.Comment.Add(context.TODO(), issue.Key, comment, nil) if err != nil { logError(err, response) return nil, fmt.Errorf("could not comment on issue %s: %w", summary, err) @@ -419,43 +457,55 @@ func logEntry(id, summary string) *log.Entry { return log.WithField("ID", id).WithField("summary", summary) } -func newIssue(project string, summary string, description string) *jira.Issue { - return &jira.Issue{ - Fields: &jira.IssueFields{ - Type: jira.IssueType{ +func newIssue(project string, summary string, description string) *models.IssueScheme { + return &models.IssueScheme{ + Fields: &models.IssueFieldsScheme{ + IssueType: &models.IssueTypeScheme{ Name: "Bug", }, - Project: jira.Project{ + Project: &models.ProjectScheme{ Key: project, }, - Summary: summary, - Description: description, - Labels: []string{"CI_Failure"}, + Summary: summary, + Description: &models.CommentNodeScheme{ + Version: 1, + Type: "doc", + Content: []*models.CommentNodeScheme{ + { + Type: "paragraph", + Content: []*models.CommentNodeScheme{ + { + Type: "text", + Text: description, + }, + }, + }, + }, + }, + Labels: []string{"CI_Failure"}, }, } } -func findMatchingIssue(search []jira.Issue, summary string) *jira.Issue { +func findMatchingIssue(search []*models.IssueScheme, summary string) *models.IssueScheme { for _, i := range search { - if i.Fields.Summary == summary { - return &i + if i.Fields != nil && i.Fields.Summary == summary { + return i } } return nil } -func logError(e error, response *jira.Response) { +func logError(e error, response *models.ResponseScheme) { if response == nil { log.WithError(e).Error("no response") return } - all, err := io.ReadAll(response.Body) - - if err != nil { - log.WithError(e).WithField("StatusCode", response.StatusCode).Errorf("Could not read body: %q", err) + if response.Bytes.String() != "" { + log.WithError(e).WithField("StatusCode", response.Code).Error("Server response: " + response.Bytes.String()) } else { - log.WithError(e).WithField("StatusCode", response.StatusCode).Error("Server response: "+string(all)) + log.WithError(e).WithField("StatusCode", response.Code).Error("no response body") } } diff --git a/go.mod b/go.mod index 468a2cd..8ce4cfd 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( cloud.google.com/go/bigquery v1.73.1 github.com/andygrunwald/go-jira v1.17.0 github.com/carlmjohnson/versioninfo v0.22.5 + github.com/ctreminiom/go-atlassian/v2 v2.3.0 github.com/hashicorp/go-multierror v1.1.1 github.com/hashicorp/go-retryablehttp v0.7.8 github.com/joshdk/go-junit v1.0.0 @@ -22,6 +23,7 @@ require ( cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect cloud.google.com/go/compute/metadata v0.9.0 // indirect cloud.google.com/go/iam v1.5.3 // indirect + dario.cat/mergo v1.0.1 // indirect github.com/apache/arrow/go/v15 v15.0.2 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect @@ -32,7 +34,7 @@ require ( github.com/goccy/go-json v0.10.2 // indirect github.com/golang-jwt/jwt/v4 v4.5.2 // indirect github.com/google/flatbuffers v23.5.26+incompatible // indirect - github.com/google/go-querystring v1.1.0 // indirect + github.com/google/go-querystring v1.2.0 // indirect github.com/google/s2a-go v0.1.9 // indirect github.com/google/uuid v1.6.0 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.7 // indirect @@ -44,6 +46,9 @@ require ( github.com/klauspost/cpuid/v2 v2.2.5 // indirect github.com/pierrec/lz4/v4 v4.1.18 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/tidwall/gjson v1.18.0 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.0 // indirect github.com/trivago/tgo v1.0.7 // indirect github.com/zeebo/xxh3 v1.0.2 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect diff --git a/go.sum b/go.sum index e4af495..2cfea2a 100644 --- a/go.sum +++ b/go.sum @@ -20,6 +20,8 @@ cloud.google.com/go/monitoring v1.24.3 h1:dde+gMNc0UhPZD1Azu6at2e79bfdztVDS5lvhO cloud.google.com/go/monitoring v1.24.3/go.mod h1:nYP6W0tm3N9H/bOw8am7t62YTzZY+zUeQ+Bi6+2eonI= cloud.google.com/go/storage v1.59.0 h1:9p3yDzEN9Vet4JnbN90FECIw6n4FCXcKBK1scxtQnw8= cloud.google.com/go/storage v1.59.0/go.mod h1:cMWbtM+anpC74gn6qjLh+exqYcfmB9Hqe5z6adx+CLI= +dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s= +dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0 h1:sBEjpZlNHzK1voKq9695PJSX2o5NEXl7/OL3coiIY0c= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0= github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.54.0 h1:lhhYARPUu3LmHysQ/igznQphfzynnqI3D75oUyw1HXk= @@ -36,6 +38,8 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cncf/xds/go v0.0.0-20251022180443-0feb69152e9f h1:Y8xYupdHxryycyPlc9Y+bSQAYZnetRJ70VMVKm5CKI0= github.com/cncf/xds/go v0.0.0-20251022180443-0feb69152e9f/go.mod h1:HlzOvOjVBOfTGSRXRyY0OiCS/3J1akRGQQpRO/7zyF4= +github.com/ctreminiom/go-atlassian/v2 v2.3.0 h1:VBrimRZw0AymNijtepwpuZTdQSUkXQM9l3klwlOtKcY= +github.com/ctreminiom/go-atlassian/v2 v2.3.0/go.mod h1:qCQUluvDg8S77TNz466qcVIm1ghxwKgGQ7qnsbz8Ulc= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -67,11 +71,11 @@ github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/flatbuffers v23.5.26+incompatible h1:M9dgRyhJemaM4Sw8+66GHBu8ioaQmyPLg1b8VwK5WJg= github.com/google/flatbuffers v23.5.26+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= -github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= -github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= +github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0= +github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU= github.com/google/martian/v3 v3.3.3 h1:DIhPTQrbPkgs2yJYdXU/eNACCG5DVQjySNRNlflZ9Fc= github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0= github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= @@ -125,9 +129,17 @@ github.com/slack-go/slack v0.17.3/go.mod h1:X+UqOufi3LYQHDnMG1vxf0J8asC6+WllXrVr github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo= github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/trivago/tgo v1.0.7 h1:uaWH/XIy9aWYWpjm2CU3RpcqZXmX2ysQ9/Go+d9gyrM= github.com/trivago/tgo v1.0.7/go.mod h1:w4dpD+3tzNIIiIfkWWa85w5/B77tlvdZckQ+6PkFnhc= github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= @@ -175,7 +187,6 @@ golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ= golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY= golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= From 873163f83c9e9ff8c1e56c783beb9001df9de4d1 Mon Sep 17 00:00:00 2001 From: Tomasz Janiszewski Date: Tue, 17 Feb 2026 16:42:19 +0100 Subject: [PATCH 2/5] Convert issue and comment templates to Atlassian Document Format (ADF) Jira Cloud no longer supports Wiki Markup formatting and requires Atlassian Document Format (ADF) for issue descriptions and comments. Changes: - Remove Wiki Markup template (desc constant) - Implement buildADFDescription() to generate proper ADF structure - Use ADF for both issue creation and comment creation - Add proper ADF node types: heading, codeBlock, table, paragraph - Fix empty field handling: use space for empty BUILD TAG and ORCHESTRATOR to ensure text nodes always have the required 'text' field - Update description() return type from string to *models.CommentNodeScheme - Update newIssue() to accept ADF description directly The ADF structure includes: - H3 headings for each section (Message, STDERR, STDOUT, ERROR) - Code blocks with language="text" for test output - Table with bold headers for Build Information - Proper text nodes with marks for links and formatting This fixes the "INVALID_INPUT" error that occurred when creating comments with empty table cells, which was caused by text nodes missing the required 'text' field. Co-Authored-By: Claude Sonnet 4.5 --- cmd/junit2jira/main.go | 352 +++++++++++++++++++++++++++++++++-------- 1 file changed, 286 insertions(+), 66 deletions(-) diff --git a/cmd/junit2jira/main.go b/cmd/junit2jira/main.go index e8c67e9..1ca0378 100644 --- a/cmd/junit2jira/main.go +++ b/cmd/junit2jira/main.go @@ -355,7 +355,7 @@ func (j junit2jira) createIssueOrComment(tc j2jTestCase) (*testIssue, error) { if issue == nil { logEntry(NA, summary).Info("Issue not found. Creating new issue...") if j.dryRun { - logEntry(NA, summary).Debugf("Dry run: will just print issue\n %q", description) + logEntry(NA, summary).Debug("Dry run: would create new issue") return nil, nil } issue = newIssue(j.jiraProject, summary, description) @@ -374,29 +374,15 @@ func (j junit2jira) createIssueOrComment(tc j2jTestCase) (*testIssue, error) { return &issueWithTestCase, nil } - // Create ADF (Atlassian Document Format) comment with plain text + // Use the same ADF description for the comment comment := &models.CommentPayloadScheme{ - Body: &models.CommentNodeScheme{ - Version: 1, - Type: "doc", - Content: []*models.CommentNodeScheme{ - { - Type: "paragraph", - Content: []*models.CommentNodeScheme{ - { - Type: "text", - Text: description, - }, - }, - }, - }, - }, + Body: description, } logEntry(issue.Key, issue.Fields.Summary).Info("Found issue. Creating a comment...") if j.dryRun { - logEntry(NA, issue.Fields.Summary).Debugf("Dry run: will just print comment:\n%q", description) + logEntry(NA, issue.Fields.Summary).Debug("Dry run: would add comment to existing issue") return &issueWithTestCase, nil } @@ -457,7 +443,7 @@ func logEntry(id, summary string) *log.Entry { return log.WithField("ID", id).WithField("summary", summary) } -func newIssue(project string, summary string, description string) *models.IssueScheme { +func newIssue(project string, summary string, description *models.CommentNodeScheme) *models.IssueScheme { return &models.IssueScheme{ Fields: &models.IssueFieldsScheme{ IssueType: &models.IssueTypeScheme{ @@ -466,23 +452,9 @@ func newIssue(project string, summary string, description string) *models.IssueS Project: &models.ProjectScheme{ Key: project, }, - Summary: summary, - Description: &models.CommentNodeScheme{ - Version: 1, - Type: "doc", - Content: []*models.CommentNodeScheme{ - { - Type: "paragraph", - Content: []*models.CommentNodeScheme{ - { - Type: "text", - Text: description, - }, - }, - }, - }, - }, - Labels: []string{"CI_Failure"}, + Summary: summary, + Description: description, + Labels: []string{"CI_Failure"}, }, } } @@ -590,34 +562,6 @@ func (j junit2jira) mergeFailedTests(failedTests []j2jTestCase) ([]j2jTestCase, } const ( - desc = ` -{{- if .Message }} -{code:title=Message|borderStyle=solid} -{{ .Message | truncate }} -{code} -{{- end }} -{{- if .Stderr }} -{code:title=STDERR|borderStyle=solid} -{{ .Stderr | truncate }} -{code} -{{- end }} -{{- if .Stdout }} -{code:title=STDOUT|borderStyle=solid} -{{ .Stdout | truncate }} -{code} -{{- end }} -{{- if .Error }} -{code:title=ERROR|borderStyle=solid} -{{ .Error | truncate }} -{code} -{{- end }} - -|| ENV || Value || -| BUILD ID | [{{- .BuildId -}}|{{- .BuildLink -}}]| -| BUILD TAG | [{{- .BuildTag -}}|{{- .BaseLink -}}]| -| JOB NAME | {{- .JobName -}} | -| ORCHESTRATOR | {{- .Orchestrator -}} | -` summaryTpl = `{{ (print .Suite " / " .Name) | truncateSummary }} FAILED` ) @@ -675,8 +619,284 @@ func newJ2jTestCase(testCase testcase.TestCase, p params) j2jTestCase { } } -func (tc *j2jTestCase) description() (string, error) { - return render(*tc, desc) +func (tc *j2jTestCase) description() (*models.CommentNodeScheme, error) { + return tc.buildADFDescription(), nil +} + +// buildADFDescription creates an Atlassian Document Format structure for the issue description +func (tc *j2jTestCase) buildADFDescription() *models.CommentNodeScheme { + content := []*models.CommentNodeScheme{} + + // Add Message section if present + if tc.Message != "" { + content = append(content, &models.CommentNodeScheme{ + Type: "heading", + Attrs: map[string]interface{}{ + "level": 3, + }, + Content: []*models.CommentNodeScheme{ + {Type: "text", Text: "Message"}, + }, + }) + content = append(content, &models.CommentNodeScheme{ + Type: "codeBlock", + Attrs: map[string]interface{}{ + "language": "text", + }, + Content: []*models.CommentNodeScheme{ + {Type: "text", Text: truncate(tc.Message)}, + }, + }) + } + + // Add STDERR section if present + if tc.Stderr != "" { + content = append(content, &models.CommentNodeScheme{ + Type: "heading", + Attrs: map[string]interface{}{ + "level": 3, + }, + Content: []*models.CommentNodeScheme{ + {Type: "text", Text: "STDERR"}, + }, + }) + content = append(content, &models.CommentNodeScheme{ + Type: "codeBlock", + Attrs: map[string]interface{}{ + "language": "text", + }, + Content: []*models.CommentNodeScheme{ + {Type: "text", Text: truncate(tc.Stderr)}, + }, + }) + } + + // Add STDOUT section if present + if tc.Stdout != "" { + content = append(content, &models.CommentNodeScheme{ + Type: "heading", + Attrs: map[string]interface{}{ + "level": 3, + }, + Content: []*models.CommentNodeScheme{ + {Type: "text", Text: "STDOUT"}, + }, + }) + content = append(content, &models.CommentNodeScheme{ + Type: "codeBlock", + Attrs: map[string]interface{}{ + "language": "text", + }, + Content: []*models.CommentNodeScheme{ + {Type: "text", Text: truncate(tc.Stdout)}, + }, + }) + } + + // Add ERROR section if present + if tc.Error != "" { + content = append(content, &models.CommentNodeScheme{ + Type: "heading", + Attrs: map[string]interface{}{ + "level": 3, + }, + Content: []*models.CommentNodeScheme{ + {Type: "text", Text: "ERROR"}, + }, + }) + content = append(content, &models.CommentNodeScheme{ + Type: "codeBlock", + Attrs: map[string]interface{}{ + "language": "text", + }, + Content: []*models.CommentNodeScheme{ + {Type: "text", Text: truncate(tc.Error)}, + }, + }) + } + + // Add Build Information table + content = append(content, &models.CommentNodeScheme{ + Type: "heading", + Attrs: map[string]interface{}{ + "level": 3, + }, + Content: []*models.CommentNodeScheme{ + {Type: "text", Text: "Build Information"}, + }, + }) + + // Create table for build info + tableRows := []*models.CommentNodeScheme{ + // Header row + { + Type: "tableRow", + Content: []*models.CommentNodeScheme{ + { + Type: "tableHeader", + Content: []*models.CommentNodeScheme{ + {Type: "paragraph", Content: []*models.CommentNodeScheme{ + {Type: "text", Text: "ENV", Marks: []*models.MarkScheme{{Type: "strong"}}}, + }}, + }, + }, + { + Type: "tableHeader", + Content: []*models.CommentNodeScheme{ + {Type: "paragraph", Content: []*models.CommentNodeScheme{ + {Type: "text", Text: "Value", Marks: []*models.MarkScheme{{Type: "strong"}}}, + }}, + }, + }, + }, + }, + } + + // Build ID row with link + buildIDContent := []*models.CommentNodeScheme{} + if tc.BuildLink != "" { + buildIDContent = append(buildIDContent, &models.CommentNodeScheme{ + Type: "text", + Text: tc.BuildId, + Marks: []*models.MarkScheme{{ + Type: "link", + Attrs: map[string]interface{}{ + "href": tc.BuildLink, + }, + }}, + }) + } else { + buildIDContent = append(buildIDContent, &models.CommentNodeScheme{ + Type: "text", + Text: tc.BuildId, + }) + } + + tableRows = append(tableRows, &models.CommentNodeScheme{ + Type: "tableRow", + Content: []*models.CommentNodeScheme{ + { + Type: "tableCell", + Content: []*models.CommentNodeScheme{ + {Type: "paragraph", Content: []*models.CommentNodeScheme{ + {Type: "text", Text: "BUILD ID"}, + }}, + }, + }, + { + Type: "tableCell", + Content: []*models.CommentNodeScheme{ + {Type: "paragraph", Content: buildIDContent}, + }, + }, + }, + }) + + // Build TAG row with link + buildTagContent := []*models.CommentNodeScheme{} + buildTagText := tc.BuildTag + if buildTagText == "" { + buildTagText = " " // Use space for empty values to ensure text field is present + } + if tc.BaseLink != "" && tc.BuildTag != "" { + buildTagContent = append(buildTagContent, &models.CommentNodeScheme{ + Type: "text", + Text: buildTagText, + Marks: []*models.MarkScheme{{ + Type: "link", + Attrs: map[string]interface{}{ + "href": tc.BaseLink, + }, + }}, + }) + } else { + buildTagContent = append(buildTagContent, &models.CommentNodeScheme{ + Type: "text", + Text: buildTagText, + }) + } + + tableRows = append(tableRows, &models.CommentNodeScheme{ + Type: "tableRow", + Content: []*models.CommentNodeScheme{ + { + Type: "tableCell", + Content: []*models.CommentNodeScheme{ + {Type: "paragraph", Content: []*models.CommentNodeScheme{ + {Type: "text", Text: "BUILD TAG"}, + }}, + }, + }, + { + Type: "tableCell", + Content: []*models.CommentNodeScheme{ + {Type: "paragraph", Content: buildTagContent}, + }, + }, + }, + }) + + // Job Name row + tableRows = append(tableRows, &models.CommentNodeScheme{ + Type: "tableRow", + Content: []*models.CommentNodeScheme{ + { + Type: "tableCell", + Content: []*models.CommentNodeScheme{ + {Type: "paragraph", Content: []*models.CommentNodeScheme{ + {Type: "text", Text: "JOB NAME"}, + }}, + }, + }, + { + Type: "tableCell", + Content: []*models.CommentNodeScheme{ + {Type: "paragraph", Content: []*models.CommentNodeScheme{ + {Type: "text", Text: tc.JobName}, + }}, + }, + }, + }, + }) + + // Orchestrator row + orchestratorText := tc.Orchestrator + if orchestratorText == "" { + orchestratorText = " " // Use space for empty values to ensure text field is present + } + tableRows = append(tableRows, &models.CommentNodeScheme{ + Type: "tableRow", + Content: []*models.CommentNodeScheme{ + { + Type: "tableCell", + Content: []*models.CommentNodeScheme{ + {Type: "paragraph", Content: []*models.CommentNodeScheme{ + {Type: "text", Text: "ORCHESTRATOR"}, + }}, + }, + }, + { + Type: "tableCell", + Content: []*models.CommentNodeScheme{ + {Type: "paragraph", Content: []*models.CommentNodeScheme{ + {Type: "text", Text: orchestratorText}, + }}, + }, + }, + }, + }) + + // Add the table to content + content = append(content, &models.CommentNodeScheme{ + Type: "table", + Content: tableRows, + }) + + return &models.CommentNodeScheme{ + Version: 1, + Type: "doc", + Content: content, + } } func (tc j2jTestCase) summary() (string, error) { From 730c5264073f4528270489cceb23454c6d12720f Mon Sep 17 00:00:00 2001 From: Tomasz Janiszewski Date: Tue, 17 Feb 2026 17:25:37 +0100 Subject: [PATCH 3/5] Update tests to use go-atlassian models instead of go-jira Fix test compilation errors after migrating from go-jira to go-atlassian: - Update imports from github.com/andygrunwald/go-jira to go-atlassian models - Replace jira.Issue with models.IssueScheme - Replace jira.IssueFields with models.IssueFieldsScheme - Update TestDescription to verify ADF structure instead of Wiki Markup - Verify ADF node types, headings, code blocks, and tables - Test truncation functionality with ADF format All tests now pass successfully. Co-Authored-By: Claude Sonnet 4.5 --- cmd/junit2jira/main_test.go | 87 +++++++++++++++++------------------- cmd/junit2jira/slack_test.go | 7 +-- 2 files changed, 46 insertions(+), 48 deletions(-) diff --git a/cmd/junit2jira/main_test.go b/cmd/junit2jira/main_test.go index 306f22c..96ea75d 100644 --- a/cmd/junit2jira/main_test.go +++ b/cmd/junit2jira/main_test.go @@ -6,7 +6,7 @@ import ( "net/url" "testing" - "github.com/andygrunwald/go-jira" + "github.com/ctreminiom/go-atlassian/v2/pkg/infra/models" "github.com/stackrox/junit2jira/pkg/testcase" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -260,29 +260,41 @@ func TestDescription(t *testing.T) { } actual, err := tc.description() assert.NoError(t, err) - assert.Equal(t, ` -{code:title=Message|borderStyle=solid} -Condition not satisfied: -waitForViolation(deploymentName, policyName, 60) -| | | -false qadefpolstruts Apache Struts: CVE-2017-5638 + // Verify ADF structure + assert.NotNil(t, actual) + assert.Equal(t, 1, actual.Version) + assert.Equal(t, "doc", actual.Type) + assert.NotEmpty(t, actual.Content) -{code} -{code:title=STDOUT|borderStyle=solid} -?[1;30m21:35:15?[0;39m | ?[34mINFO ?[0;39m | DefaultPoliciesTest | Starting testcase -?[1;30m21:36:16?[0;39m | ?[34mINFO ?[0;39m | Services | Failed to trigger Apache Struts: CVE-2017-5638 after waiting 60 seconds -?[1;30m21:36:16?[0;39m | ?[1;31mERROR?[0;39m | Helpers | An exception occurred in test -org.spockframework.runtime.ConditionNotSatisfiedError: Condition not satisfied: + // Should have Message section (heading + codeBlock) and STDOUT section (heading + codeBlock) and Build Information (heading + table) + // Total: 6 elements (2 for Message, 2 for STDOUT, 2 for Build Info) + assert.Equal(t, 6, len(actual.Content)) -{code} + // Check Message heading + assert.Equal(t, "heading", actual.Content[0].Type) + assert.Equal(t, "Message", actual.Content[0].Content[0].Text) + + // Check Message codeBlock + assert.Equal(t, "codeBlock", actual.Content[1].Type) + assert.Contains(t, actual.Content[1].Content[0].Text, "Condition not satisfied") + + // Check STDOUT heading + assert.Equal(t, "heading", actual.Content[2].Type) + assert.Equal(t, "STDOUT", actual.Content[2].Content[0].Text) + + // Check STDOUT codeBlock + assert.Equal(t, "codeBlock", actual.Content[3].Type) + assert.Contains(t, actual.Content[3].Content[0].Text, "DefaultPoliciesTest") + + // Check Build Information heading + assert.Equal(t, "heading", actual.Content[4].Type) + assert.Equal(t, "Build Information", actual.Content[4].Content[0].Text) + + // Check Build Information table + assert.Equal(t, "table", actual.Content[5].Type) + assert.NotEmpty(t, actual.Content[5].Content) // Should have rows -|| ENV || Value || -| BUILD ID | [1|https://prow.ci.openshift.org/view/gs/origin-ci-test/logs/1]| -| BUILD TAG | [|]| -| JOB NAME || -| ORCHESTRATOR || -`, actual) s, err := tc.summary() assert.NoError(t, err) assert.Equal(t, `DefaultPoliciesTest / Verify policy Apache Struts CVE-2017-5638 is triggered FAILED`, s) @@ -295,26 +307,11 @@ org.spockframework.runtime.ConditionNotSatisfiedError: Condition not satisfied: maxTextBlockLength = 100 actual, err = tc.description() assert.NoError(t, err) - assert.Equal(t, ` -{code:title=Message|borderStyle=solid} -Condition not satisfied: - -waitForViolation(deploymentName, policyName, 60) -| | - … too long, truncated. -{code} -{code:title=STDOUT|borderStyle=solid} -?[1;30m21:35:15?[0;39m | ?[34mINFO ?[0;39m | DefaultPoliciesTest | Starting testcase -?[1;30m21 - … too long, truncated. -{code} -|| ENV || Value || -| BUILD ID | [1|https://prow.ci.openshift.org/view/gs/origin-ci-test/logs/1]| -| BUILD TAG | [|]| -| JOB NAME || -| ORCHESTRATOR || -`, actual) + // Verify truncation works with ADF + assert.NotNil(t, actual) + assert.Equal(t, "codeBlock", actual.Content[1].Type) + assert.Contains(t, actual.Content[1].Content[0].Text, "… too long, truncated") } func TestCsvOutput(t *testing.T) { @@ -423,9 +420,9 @@ func TestHtmlOutput(t *testing.T) { buf := bytes.NewBufferString("") require.NoError(t, j.renderHtml(nil, buf)) - issues := []*jira.Issue{ - {Key: "ROX-1", Fields: &jira.IssueFields{Summary: "abc"}}, - {Key: "ROX-2", Fields: &jira.IssueFields{Summary: "def"}}, + issues := []*models.IssueScheme{ + {Key: "ROX-1", Fields: &models.IssueFieldsScheme{Summary: "abc"}}, + {Key: "ROX-2", Fields: &models.IssueFieldsScheme{Summary: "def"}}, {Key: "ROX-3"}, } buf = bytes.NewBufferString("") @@ -445,17 +442,17 @@ func TestSummaryNoFailures(t *testing.T) { expectedSummarySomeNewJIRAs := `{"newJIRAs":2}` tc := []*testIssue{ { - issue: &jira.Issue{Key: "ROX-1"}, + issue: &models.IssueScheme{Key: "ROX-1"}, newJIRA: false, testCase: j2jTestCase{}, }, { - issue: &jira.Issue{Key: "ROX-2"}, + issue: &models.IssueScheme{Key: "ROX-2"}, newJIRA: true, testCase: j2jTestCase{}, }, { - issue: &jira.Issue{Key: "ROX-3"}, + issue: &models.IssueScheme{Key: "ROX-3"}, newJIRA: true, testCase: j2jTestCase{}, }, diff --git a/cmd/junit2jira/slack_test.go b/cmd/junit2jira/slack_test.go index 54600c2..a784f88 100644 --- a/cmd/junit2jira/slack_test.go +++ b/cmd/junit2jira/slack_test.go @@ -4,10 +4,11 @@ import ( _ "embed" "encoding/json" "fmt" - "github.com/andygrunwald/go-jira" + "testing" + + "github.com/ctreminiom/go-atlassian/v2/pkg/infra/models" "github.com/joshdk/go-junit" "github.com/stretchr/testify/assert" - "testing" ) var ( @@ -50,7 +51,7 @@ func TestConstructSlackMessage(t *testing.T) { testCase: s, }) } - issues[0].issue = &jira.Issue{ + issues[0].issue = &models.IssueScheme{ Self: "some/url/foo-1", Key: "FOO-1", } From 1b0eb7aa2ebaf6c5936798830400c5fb554c4d83 Mon Sep 17 00:00:00 2001 From: Tomasz Janiszewski Date: Mon, 16 Mar 2026 13:15:13 +0100 Subject: [PATCH 4/5] Apply suggestions from code review Co-authored-by: Marcin Owsiany --- cmd/junit2jira/main.go | 10 ++-------- cmd/junit2jira/main_test.go | 6 ------ 2 files changed, 2 insertions(+), 14 deletions(-) diff --git a/cmd/junit2jira/main.go b/cmd/junit2jira/main.go index 1ca0378..3d3ae07 100644 --- a/cmd/junit2jira/main.go +++ b/cmd/junit2jira/main.go @@ -108,13 +108,11 @@ func run(p params) error { log.Fatal("JIRA_USER (email) and JIRA_TOKEN are required for Jira Cloud authentication") } - // Create Jira client using go-atlassian library jiraClient, err := jira.New(nil, p.jiraUrl.String()) if err != nil { return errors.Wrapf(err, "could not create client for %s", p.jiraUrl) } - // Set Basic Auth with email and API token jiraClient.Auth.SetBasicAuth(jiraUser, jiraToken) log.Info("Using Basic Auth (email + API token)") @@ -649,7 +647,6 @@ func (tc *j2jTestCase) buildADFDescription() *models.CommentNodeScheme { }) } - // Add STDERR section if present if tc.Stderr != "" { content = append(content, &models.CommentNodeScheme{ Type: "heading", @@ -671,7 +668,6 @@ func (tc *j2jTestCase) buildADFDescription() *models.CommentNodeScheme { }) } - // Add STDOUT section if present if tc.Stdout != "" { content = append(content, &models.CommentNodeScheme{ Type: "heading", @@ -693,7 +689,6 @@ func (tc *j2jTestCase) buildADFDescription() *models.CommentNodeScheme { }) } - // Add ERROR section if present if tc.Error != "" { content = append(content, &models.CommentNodeScheme{ Type: "heading", @@ -796,7 +791,7 @@ func (tc *j2jTestCase) buildADFDescription() *models.CommentNodeScheme { buildTagContent := []*models.CommentNodeScheme{} buildTagText := tc.BuildTag if buildTagText == "" { - buildTagText = " " // Use space for empty values to ensure text field is present + buildTagText = " " // Use space for empty values to ensure text field is present, required by the API } if tc.BaseLink != "" && tc.BuildTag != "" { buildTagContent = append(buildTagContent, &models.CommentNodeScheme{ @@ -862,7 +857,7 @@ func (tc *j2jTestCase) buildADFDescription() *models.CommentNodeScheme { // Orchestrator row orchestratorText := tc.Orchestrator if orchestratorText == "" { - orchestratorText = " " // Use space for empty values to ensure text field is present + orchestratorText = " " // Use space for empty values to ensure text field is present, required by the API } tableRows = append(tableRows, &models.CommentNodeScheme{ Type: "tableRow", @@ -886,7 +881,6 @@ func (tc *j2jTestCase) buildADFDescription() *models.CommentNodeScheme { }, }) - // Add the table to content content = append(content, &models.CommentNodeScheme{ Type: "table", Content: tableRows, diff --git a/cmd/junit2jira/main_test.go b/cmd/junit2jira/main_test.go index 96ea75d..25e4d8e 100644 --- a/cmd/junit2jira/main_test.go +++ b/cmd/junit2jira/main_test.go @@ -271,27 +271,21 @@ func TestDescription(t *testing.T) { // Total: 6 elements (2 for Message, 2 for STDOUT, 2 for Build Info) assert.Equal(t, 6, len(actual.Content)) - // Check Message heading assert.Equal(t, "heading", actual.Content[0].Type) assert.Equal(t, "Message", actual.Content[0].Content[0].Text) - // Check Message codeBlock assert.Equal(t, "codeBlock", actual.Content[1].Type) assert.Contains(t, actual.Content[1].Content[0].Text, "Condition not satisfied") - // Check STDOUT heading assert.Equal(t, "heading", actual.Content[2].Type) assert.Equal(t, "STDOUT", actual.Content[2].Content[0].Text) - // Check STDOUT codeBlock assert.Equal(t, "codeBlock", actual.Content[3].Type) assert.Contains(t, actual.Content[3].Content[0].Text, "DefaultPoliciesTest") - // Check Build Information heading assert.Equal(t, "heading", actual.Content[4].Type) assert.Equal(t, "Build Information", actual.Content[4].Content[0].Text) - // Check Build Information table assert.Equal(t, "table", actual.Content[5].Type) assert.NotEmpty(t, actual.Content[5].Content) // Should have rows From 5ce126b53de7f20208a3219c67a9d37aec0dde3d Mon Sep 17 00:00:00 2001 From: Tomasz Janiszewski Date: Mon, 16 Mar 2026 13:22:47 +0100 Subject: [PATCH 5/5] docs: add Jira API token documentation Add authentication documentation to README and include the API token URL in the error message to help users obtain their credentials. Addresses PR feedback requesting token URL documentation. Co-Authored-By: Claude Sonnet 4.5 --- README.md | 10 +++++++++- cmd/junit2jira/main.go | 2 +- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 6c62de5..72e00e8 100644 --- a/README.md +++ b/README.md @@ -61,9 +61,17 @@ Usage of junit2jira: print version information and exit ``` +*Authentication* + +For Jira Cloud authentication, you need to provide: +- `JIRA_USER`: Your Jira account email address +- `JIRA_TOKEN`: Your Jira API token (get it from https://id.atlassian.com/manage-profile/security/api-tokens) + *Example usage* ```shell -JIRA_TOKEN="..." junit2jira \ +JIRA_USER="user@example.com" \ +JIRA_TOKEN="..." \ +junit2jira \ -jira-url "https://..." \ -junit-reports-dir "..." \ -base-link "https://..." \ diff --git a/cmd/junit2jira/main.go b/cmd/junit2jira/main.go index 3d3ae07..71c81bc 100644 --- a/cmd/junit2jira/main.go +++ b/cmd/junit2jira/main.go @@ -105,7 +105,7 @@ func run(p params) error { } if jiraUser == "" || jiraToken == "" { - log.Fatal("JIRA_USER (email) and JIRA_TOKEN are required for Jira Cloud authentication") + log.Fatal("JIRA_USER (email) and JIRA_TOKEN are required for Jira Cloud authentication. Get your API token at https://id.atlassian.com/manage-profile/security/api-tokens") } jiraClient, err := jira.New(nil, p.jiraUrl.String())