github.com/testrecall/reporter@v0.2.3-0.20240102230324-a312dcb6d921/reporter/reporter.go (about)

     1  package reporter
     2  
     3  import (
     4  	"crypto/rand"
     5  	"encoding/base64"
     6  	"errors"
     7  	"fmt"
     8  	"os"
     9  	"os/exec"
    10  	"path/filepath"
    11  	"strings"
    12  	"time"
    13  
    14  	junit "github.com/joshdk/go-junit"
    15  	"github.com/sirupsen/logrus"
    16  	"github.com/testrecall/reporter/ci"
    17  )
    18  
    19  var branchCommand = strings.Fields("git log -n 1 --pretty=%D HEAD")
    20  
    21  const MultiErrorMessage = `
    22  valid values: before/partial/after
    23  
    24  multi allows TestRecall to group multiple or parallel test reports together.
    25  Otherwise each partial report will be considered its on build.
    26  
    27   - 'before' must be sent before any results are uploaded, to let TestRecall
    28  know that multiple reports will be uploaded
    29   - 'partial' must be sent with any results that will be grouped together
    30   - 'after' must be called after all uploads are finished, to prevent
    31  acidenitally combining extra files`
    32  
    33  const noTokenMessage = `
    34  	TR_UPLOAD_TOKEN must be set in the environment,
    35  	find the token for this project here:
    36  	app.testrecall.com/projects/<my-project>/integrations
    37  `
    38  
    39  func noFileMessage(filename string) string {
    40  	return fmt.Sprintf(`
    41  		unable to find file to upload results at: %s
    42  		check if the path is correct and being passed to the reporter
    43  	`, filename)
    44  }
    45  
    46  const (
    47  	MultiBefore  = "before"
    48  	MutliPartial = "partial"
    49  	MultiAfter   = "after"
    50  )
    51  
    52  const IdempotencyKeyHeader = "Idempotency-Key"
    53  
    54  type RequestPayload struct {
    55  	IdempotencyKey string
    56  	UploadToken    string
    57  	Filename       string
    58  
    59  	RequestData RequestData
    60  
    61  	Logger *logrus.Logger
    62  
    63  	Vendor ci.IVendor
    64  }
    65  
    66  type RequestData struct {
    67  	RunData   [][]byte `json:"run"`
    68  	Filenames []string `json:"file_names"`
    69  	Multi     string   `json:"multi"`
    70  
    71  	Hostname        string            `json:"hostname"`
    72  	ReporterVersion string            `json:"reporter_version"`
    73  	Flags           map[string]string `json:"flags"`
    74  
    75  	Branch string `json:"branch"`
    76  	SHA    string `json:"sha"`
    77  	Tag    string `json:"tag"`
    78  	PR     string `json:"pr"`
    79  
    80  	Slug        string `json:"slug"`
    81  	CIName      string `json:"ci_name"`
    82  	BuildNumber string `json:"build_number"`
    83  	BuildURL    string `json:"build_url"`
    84  	Job         string `json:"job"`
    85  }
    86  
    87  func (r *RequestPayload) Setup() {
    88  	r.IdempotencyKey = newIdempotencyKey()
    89  
    90  	r.GetUploadToken()
    91  	r.GetHostname()
    92  	r.GetRunData()
    93  
    94  	r.GetVendor()
    95  
    96  	r.GetSHA()
    97  	r.GetBranch()
    98  	r.GetBuildNumber()
    99  	r.GetBuildURL()
   100  }
   101  
   102  func newIdempotencyKey() string {
   103  	now := time.Now().UnixNano()
   104  	buf := make([]byte, 4)
   105  	if _, err := rand.Read(buf); err != nil {
   106  		panic(err)
   107  	}
   108  	return fmt.Sprintf("%v_%v", now, base64.URLEncoding.EncodeToString(buf)[:6])
   109  }
   110  
   111  func (r RequestPayload) isVendorKnown() bool {
   112  	return r.RequestData.CIName != ""
   113  }
   114  
   115  func (r RequestPayload) FailureCount() (int, bool) {
   116  	if mutli, _ := r.isMulti(); mutli {
   117  		return 0, true
   118  	}
   119  
   120  	run, err := junit.Ingest(r.RequestData.RunData[0])
   121  	if err != nil {
   122  		r.Logger.Debug(err)
   123  		return 0, false
   124  	}
   125  
   126  	count := 0
   127  	for _, s := range run {
   128  		count += s.Totals.Failed
   129  	}
   130  	return count, true
   131  }
   132  
   133  func (r *RequestPayload) GetVendor() {
   134  	if vendor, found := ci.GetVendor(); found {
   135  		r.Vendor = vendor
   136  		r.RequestData.CIName = r.Vendor.GetName()
   137  	}
   138  }
   139  
   140  func (r *RequestPayload) GetUploadToken() {
   141  	r.UploadToken = os.Getenv("TR_UPLOAD_TOKEN")
   142  	if r.UploadToken == "" {
   143  		r.Logger.Fatal(noTokenMessage)
   144  	}
   145  }
   146  
   147  func (r *RequestPayload) GetBranch() {
   148  	if r.RequestData.Branch != "" {
   149  		return
   150  	}
   151  
   152  	if r.isVendorKnown() {
   153  		r.RequestData.Branch = r.Vendor.GetBranch()
   154  		if r.RequestData.Branch != "" {
   155  			return
   156  		}
   157  	}
   158  
   159  	if _, err := exec.LookPath("git"); err != nil {
   160  		r.Logger.Fatal("-sha is a required field")
   161  	}
   162  
   163  	// NOTE: ci may be in a detached head
   164  	out, err := exec.Command(branchCommand[0], branchCommand[1:]...).CombinedOutput()
   165  	r.Logger.Debugln("branch: ", string(out))
   166  	if err != nil {
   167  		r.Logger.Fatal("git error checking for detached head ", err)
   168  	}
   169  	rawOut := string(out)
   170  	r.RequestData.Branch = GitBranchFromInfo(rawOut)
   171  
   172  	r.Logger.Debugln(r.RequestData.Branch, rawOut)
   173  }
   174  
   175  func GitBranchFromInfo(info string) string {
   176  	trimmed := strings.TrimSuffix(info, "\n")
   177  
   178  	forward := strings.Split(trimmed, "->")
   179  	var branch string
   180  	if len(forward) >= 2 {
   181  		branches := strings.Split(forward[1], ",")
   182  		branch = branches[0]
   183  	} else {
   184  		branches := strings.Split(trimmed, ",")
   185  		branch = branches[len(branches)-1]
   186  	}
   187  	return strings.TrimSpace(branch)
   188  }
   189  
   190  func (r *RequestPayload) GetSHA() {
   191  	if r.RequestData.SHA != "" {
   192  		return
   193  	}
   194  
   195  	if r.isVendorKnown() {
   196  		r.RequestData.SHA = r.Vendor.GetSHA()
   197  		if r.RequestData.SHA != "" {
   198  			return
   199  		}
   200  	}
   201  
   202  	if _, err := exec.LookPath("git"); err != nil {
   203  		r.Logger.Fatal("-sha is a required field")
   204  	}
   205  
   206  	out, err := exec.Command("git", "rev-parse", "HEAD").CombinedOutput()
   207  	if err != nil {
   208  		r.Logger.Fatal("git error using rev-parse", err)
   209  	}
   210  	rawOut := string(out)
   211  	rawOut = strings.TrimSuffix(rawOut, "\n")
   212  	r.RequestData.SHA = rawOut
   213  }
   214  
   215  func (r *RequestPayload) GetHostname() {
   216  	if r.RequestData.Hostname != "" {
   217  		return
   218  	}
   219  
   220  	h, err := os.Hostname()
   221  	if err != nil {
   222  		r.Logger.Fatal("unable to detect hostname", err)
   223  	}
   224  	r.RequestData.Hostname = h
   225  }
   226  
   227  func (r *RequestPayload) GetBuildNumber() {
   228  	if r.RequestData.BuildNumber != "" {
   229  		return
   230  	}
   231  
   232  	r.Logger.Debugf("vendor: %v", r.isVendorKnown())
   233  	if r.isVendorKnown() {
   234  		r.Logger.Debugf("vendor build number: %v", r.Vendor.GetBuildNumber())
   235  		r.RequestData.BuildNumber = r.Vendor.GetBuildNumber()
   236  		if r.RequestData.BuildNumber != "" {
   237  			return
   238  		}
   239  	}
   240  }
   241  
   242  func (r *RequestPayload) GetBuildURL() {
   243  	if r.RequestData.BuildURL != "" {
   244  		return
   245  	}
   246  
   247  	if r.isVendorKnown() {
   248  		r.RequestData.BuildURL = r.Vendor.GetBuildURL()
   249  		if r.RequestData.BuildURL != "" {
   250  			return
   251  		}
   252  	}
   253  }
   254  
   255  func (r *RequestPayload) isMulti() (bool, error) {
   256  	multi := r.RequestData.Multi
   257  	switch multi {
   258  	case "":
   259  		return false, nil
   260  	case MultiBefore, MutliPartial, MultiAfter:
   261  		return true, nil
   262  	default:
   263  		return false, errors.New(multi)
   264  	}
   265  }
   266  
   267  func (r *RequestPayload) GetRunData() {
   268  	multi, err := r.isMulti()
   269  	if err != nil {
   270  		r.Logger.Fatal(err, MultiErrorMessage)
   271  	}
   272  
   273  	files, err := SearchReportFiles(r.Filename)
   274  	if err != nil {
   275  		r.Logger.Fatal(err, noFileMessage(r.Filename))
   276  	}
   277  	r.RequestData.Filenames = files
   278  
   279  	for _, file := range files {
   280  		data, err := os.ReadFile(file)
   281  		if err != nil {
   282  			r.Logger.Fatal(err, noFileMessage(file))
   283  		}
   284  		r.RequestData.RunData = append(r.RequestData.RunData, data)
   285  	}
   286  
   287  	if r.Filename == "" && len(r.RequestData.RunData) == 0 {
   288  		// multi allows uploading no data
   289  		if !multi {
   290  			r.Logger.Fatal("-file is a required field")
   291  		}
   292  	}
   293  }
   294  
   295  var defaultPatterns = []string{
   296  	"./*/*/TEST-*.xml",
   297  	"./*/*/*/TEST-*.xml",
   298  	"./*/*/*/*/TEST-*.xml",
   299  	"./*/*/*/*/*/TEST-*.xml",
   300  
   301  	"junit*.xml",
   302  	"rspec*.xml",
   303  	"report*.xml",
   304  
   305  	"./reports/junit*.xml",
   306  	"./reports/rspec*.xml",
   307  	"./reports/report*.xml",
   308  
   309  	"./test-results/junit*.xml",
   310  	"./test-results/rspec*.xml",
   311  	"./test-results/report*.xml",
   312  
   313  	"/tmp/test-results/junit*.xml",
   314  	"/tmp/test-results/rspec*.xml",
   315  	"/tmp/test-results/report*.xml",
   316  }
   317  
   318  func SearchReportFiles(pattern string) ([]string, error) {
   319  	if pattern != "" {
   320  		return filepath.Glob(pattern)
   321  	}
   322  
   323  	for _, p := range defaultPatterns {
   324  		if matched, err := filepath.Glob(p); err != nil || len(matched) > 0 {
   325  			return matched, err
   326  		}
   327  	}
   328  
   329  	return []string{}, nil
   330  }