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 }