From d5f238c8e35c4305e620c5ec57b88f26001cc0ba Mon Sep 17 00:00:00 2001 From: Tomasz Janiszewski Date: Tue, 17 Feb 2026 16:12:50 +0100 Subject: [PATCH 1/3] 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/3] 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/3] 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", }