github.com/ungtb10d/cli/v2@v2.0.0-20221110210412-98537dd9d6a1/pkg/cmd/run/shared/shared.go (about) 1 package shared 2 3 import ( 4 "archive/zip" 5 "errors" 6 "fmt" 7 "net/url" 8 "reflect" 9 "strings" 10 "time" 11 12 "github.com/AlecAivazis/survey/v2" 13 "github.com/ungtb10d/cli/v2/api" 14 "github.com/ungtb10d/cli/v2/internal/ghrepo" 15 workflowShared "github.com/ungtb10d/cli/v2/pkg/cmd/workflow/shared" 16 "github.com/ungtb10d/cli/v2/pkg/iostreams" 17 "github.com/ungtb10d/cli/v2/pkg/prompt" 18 ) 19 20 const ( 21 // Run statuses 22 Queued Status = "queued" 23 Completed Status = "completed" 24 InProgress Status = "in_progress" 25 Requested Status = "requested" 26 Waiting Status = "waiting" 27 28 // Run conclusions 29 ActionRequired Conclusion = "action_required" 30 Cancelled Conclusion = "cancelled" 31 Failure Conclusion = "failure" 32 Neutral Conclusion = "neutral" 33 Skipped Conclusion = "skipped" 34 Stale Conclusion = "stale" 35 StartupFailure Conclusion = "startup_failure" 36 Success Conclusion = "success" 37 TimedOut Conclusion = "timed_out" 38 39 AnnotationFailure Level = "failure" 40 AnnotationWarning Level = "warning" 41 ) 42 43 type Status string 44 type Conclusion string 45 type Level string 46 47 var RunFields = []string{ 48 "name", 49 "displayTitle", 50 "headBranch", 51 "headSha", 52 "createdAt", 53 "updatedAt", 54 "startedAt", 55 "status", 56 "conclusion", 57 "event", 58 "number", 59 "databaseId", 60 "workflowDatabaseId", 61 "workflowName", 62 "url", 63 } 64 65 var SingleRunFields = append(RunFields, "jobs") 66 67 type Run struct { 68 Name string `json:"name"` // the semantics of this field are unclear 69 DisplayTitle string `json:"display_title"` 70 CreatedAt time.Time `json:"created_at"` 71 UpdatedAt time.Time `json:"updated_at"` 72 StartedAt time.Time `json:"run_started_at"` 73 Status Status 74 Conclusion Conclusion 75 Event string 76 ID int64 77 workflowName string // cache column 78 WorkflowID int64 `json:"workflow_id"` 79 Number int64 `json:"run_number"` 80 Attempts uint8 `json:"run_attempt"` 81 HeadBranch string `json:"head_branch"` 82 JobsURL string `json:"jobs_url"` 83 HeadCommit Commit `json:"head_commit"` 84 HeadSha string `json:"head_sha"` 85 URL string `json:"html_url"` 86 HeadRepository Repo `json:"head_repository"` 87 Jobs []Job `json:"-"` // populated by GetJobs 88 } 89 90 func (r *Run) StartedTime() time.Time { 91 if r.StartedAt.IsZero() { 92 return r.CreatedAt 93 } 94 return r.StartedAt 95 } 96 97 func (r *Run) Duration(now time.Time) time.Duration { 98 endTime := r.UpdatedAt 99 if r.Status != Completed { 100 endTime = now 101 } 102 d := endTime.Sub(r.StartedTime()) 103 if d < 0 { 104 return 0 105 } 106 return d.Round(time.Second) 107 } 108 109 type Repo struct { 110 Owner struct { 111 Login string 112 } 113 Name string 114 } 115 116 type Commit struct { 117 Message string 118 } 119 120 // Title is the display title for a run, falling back to the commit subject if unavailable 121 func (r Run) Title() string { 122 if r.DisplayTitle != "" { 123 return r.DisplayTitle 124 } 125 126 commitLines := strings.Split(r.HeadCommit.Message, "\n") 127 if len(commitLines) > 0 { 128 return commitLines[0] 129 } else { 130 return r.HeadSha[0:8] 131 } 132 } 133 134 // WorkflowName returns the human-readable name of the workflow that this run belongs to. 135 // TODO: consider lazy-loading the underlying API data to avoid extra API calls unless necessary 136 func (r Run) WorkflowName() string { 137 return r.workflowName 138 } 139 140 func (r *Run) ExportData(fields []string) map[string]interface{} { 141 v := reflect.ValueOf(r).Elem() 142 fieldByName := func(v reflect.Value, field string) reflect.Value { 143 return v.FieldByNameFunc(func(s string) bool { 144 return strings.EqualFold(field, s) 145 }) 146 } 147 data := map[string]interface{}{} 148 149 for _, f := range fields { 150 switch f { 151 case "databaseId": 152 data[f] = r.ID 153 case "workflowDatabaseId": 154 data[f] = r.WorkflowID 155 case "workflowName": 156 data[f] = r.WorkflowName() 157 case "jobs": 158 jobs := make([]interface{}, 0, len(r.Jobs)) 159 for _, j := range r.Jobs { 160 steps := make([]interface{}, 0, len(j.Steps)) 161 for _, s := range j.Steps { 162 steps = append(steps, map[string]interface{}{ 163 "name": s.Name, 164 "status": s.Status, 165 "conclusion": s.Conclusion, 166 "number": s.Number, 167 }) 168 } 169 var completedAt *time.Time 170 if !j.CompletedAt.IsZero() { 171 completedAt = &j.CompletedAt 172 } 173 jobs = append(jobs, map[string]interface{}{ 174 "databaseId": j.ID, 175 "status": j.Status, 176 "conclusion": j.Conclusion, 177 "name": j.Name, 178 "steps": steps, 179 "startedAt": j.StartedAt, 180 "completedAt": completedAt, 181 "url": j.URL, 182 }) 183 data[f] = jobs 184 } 185 default: 186 sf := fieldByName(v, f) 187 data[f] = sf.Interface() 188 } 189 } 190 191 return data 192 } 193 194 type Job struct { 195 ID int64 196 Status Status 197 Conclusion Conclusion 198 Name string 199 Steps Steps 200 StartedAt time.Time `json:"started_at"` 201 CompletedAt time.Time `json:"completed_at"` 202 URL string `json:"html_url"` 203 RunID int64 `json:"run_id"` 204 } 205 206 type Step struct { 207 Name string 208 Status Status 209 Conclusion Conclusion 210 Number int 211 Log *zip.File 212 } 213 214 type Steps []Step 215 216 func (s Steps) Len() int { return len(s) } 217 func (s Steps) Less(i, j int) bool { return s[i].Number < s[j].Number } 218 func (s Steps) Swap(i, j int) { s[i], s[j] = s[j], s[i] } 219 220 type Annotation struct { 221 JobName string 222 Message string 223 Path string 224 Level Level `json:"annotation_level"` 225 StartLine int `json:"start_line"` 226 } 227 228 func AnnotationSymbol(cs *iostreams.ColorScheme, a Annotation) string { 229 switch a.Level { 230 case AnnotationFailure: 231 return cs.FailureIcon() 232 case AnnotationWarning: 233 return cs.WarningIcon() 234 default: 235 return "-" 236 } 237 } 238 239 type CheckRun struct { 240 ID int64 241 } 242 243 func GetAnnotations(client *api.Client, repo ghrepo.Interface, job Job) ([]Annotation, error) { 244 var result []*Annotation 245 246 path := fmt.Sprintf("repos/%s/check-runs/%d/annotations", ghrepo.FullName(repo), job.ID) 247 248 err := client.REST(repo.RepoHost(), "GET", path, nil, &result) 249 if err != nil { 250 var httpError api.HTTPError 251 if errors.As(err, &httpError) && httpError.StatusCode == 404 { 252 return []Annotation{}, nil 253 } 254 return nil, err 255 } 256 257 out := []Annotation{} 258 259 for _, annotation := range result { 260 annotation.JobName = job.Name 261 out = append(out, *annotation) 262 } 263 264 return out, nil 265 } 266 267 func IsFailureState(c Conclusion) bool { 268 switch c { 269 case ActionRequired, Failure, StartupFailure, TimedOut: 270 return true 271 default: 272 return false 273 } 274 } 275 276 type RunsPayload struct { 277 TotalCount int `json:"total_count"` 278 WorkflowRuns []Run `json:"workflow_runs"` 279 } 280 281 type FilterOptions struct { 282 Branch string 283 Actor string 284 WorkflowID int64 285 // avoid loading workflow name separately and use the provided one 286 WorkflowName string 287 } 288 289 // GetRunsWithFilter fetches 50 runs from the API and filters them in-memory 290 func GetRunsWithFilter(client *api.Client, repo ghrepo.Interface, opts *FilterOptions, limit int, f func(Run) bool) ([]Run, error) { 291 runs, err := GetRuns(client, repo, opts, 50) 292 if err != nil { 293 return nil, err 294 } 295 296 var filtered []Run 297 for _, run := range runs.WorkflowRuns { 298 if f(run) { 299 filtered = append(filtered, run) 300 } 301 if len(filtered) == limit { 302 break 303 } 304 } 305 306 return filtered, nil 307 } 308 309 func GetRuns(client *api.Client, repo ghrepo.Interface, opts *FilterOptions, limit int) (*RunsPayload, error) { 310 path := fmt.Sprintf("repos/%s/actions/runs", ghrepo.FullName(repo)) 311 if opts != nil && opts.WorkflowID > 0 { 312 path = fmt.Sprintf("repos/%s/actions/workflows/%d/runs", ghrepo.FullName(repo), opts.WorkflowID) 313 } 314 315 perPage := limit 316 if limit > 100 { 317 perPage = 100 318 } 319 path += fmt.Sprintf("?per_page=%d", perPage) 320 path += "&exclude_pull_requests=true" // significantly reduces payload size 321 322 if opts != nil { 323 if opts.Branch != "" { 324 path += fmt.Sprintf("&branch=%s", url.QueryEscape(opts.Branch)) 325 } 326 if opts.Actor != "" { 327 path += fmt.Sprintf("&actor=%s", url.QueryEscape(opts.Actor)) 328 } 329 } 330 331 var result *RunsPayload 332 333 pagination: 334 for path != "" { 335 var response RunsPayload 336 var err error 337 path, err = client.RESTWithNext(repo.RepoHost(), "GET", path, nil, &response) 338 if err != nil { 339 return nil, err 340 } 341 342 if result == nil { 343 result = &response 344 if len(result.WorkflowRuns) == limit { 345 break pagination 346 } 347 } else { 348 for _, run := range response.WorkflowRuns { 349 result.WorkflowRuns = append(result.WorkflowRuns, run) 350 if len(result.WorkflowRuns) == limit { 351 break pagination 352 } 353 } 354 } 355 } 356 357 if opts != nil && opts.WorkflowName != "" { 358 for i := range result.WorkflowRuns { 359 result.WorkflowRuns[i].workflowName = opts.WorkflowName 360 } 361 } else if len(result.WorkflowRuns) > 0 { 362 if err := preloadWorkflowNames(client, repo, result.WorkflowRuns); err != nil { 363 return result, err 364 } 365 } 366 367 return result, nil 368 } 369 370 func preloadWorkflowNames(client *api.Client, repo ghrepo.Interface, runs []Run) error { 371 workflows, err := workflowShared.GetWorkflows(client, repo, 0) 372 if err != nil { 373 return err 374 } 375 376 workflowMap := map[int64]string{} 377 for _, wf := range workflows { 378 workflowMap[wf.ID] = wf.Name 379 } 380 381 for i, run := range runs { 382 if _, ok := workflowMap[run.WorkflowID]; !ok { 383 // Look up workflow by ID because it may have been deleted 384 workflow, err := workflowShared.GetWorkflow(client, repo, run.WorkflowID) 385 if err != nil { 386 return err 387 } 388 workflowMap[run.WorkflowID] = workflow.Name 389 } 390 runs[i].workflowName = workflowMap[run.WorkflowID] 391 } 392 return nil 393 } 394 395 type JobsPayload struct { 396 Jobs []Job 397 } 398 399 func GetJobs(client *api.Client, repo ghrepo.Interface, run *Run) ([]Job, error) { 400 if run.Jobs != nil { 401 return run.Jobs, nil 402 } 403 var result JobsPayload 404 if err := client.REST(repo.RepoHost(), "GET", run.JobsURL, nil, &result); err != nil { 405 return nil, err 406 } 407 run.Jobs = result.Jobs 408 return result.Jobs, nil 409 } 410 411 func GetJob(client *api.Client, repo ghrepo.Interface, jobID string) (*Job, error) { 412 path := fmt.Sprintf("repos/%s/actions/jobs/%s", ghrepo.FullName(repo), jobID) 413 414 var result Job 415 err := client.REST(repo.RepoHost(), "GET", path, nil, &result) 416 if err != nil { 417 return nil, err 418 } 419 420 return &result, nil 421 } 422 423 func PromptForRun(cs *iostreams.ColorScheme, runs []Run) (string, error) { 424 var selected int 425 now := time.Now() 426 427 candidates := []string{} 428 429 for _, run := range runs { 430 symbol, _ := Symbol(cs, run.Status, run.Conclusion) 431 candidates = append(candidates, 432 // TODO truncate commit message, long ones look terrible 433 fmt.Sprintf("%s %s, %s (%s) %s", symbol, run.Title(), run.WorkflowName(), run.HeadBranch, preciseAgo(now, run.StartedTime()))) 434 } 435 436 // TODO consider custom filter so it's fuzzier. right now matches start anywhere in string but 437 // become contiguous 438 //nolint:staticcheck // SA1019: prompt.SurveyAskOne is deprecated: use Prompter 439 err := prompt.SurveyAskOne(&survey.Select{ 440 Message: "Select a workflow run", 441 Options: candidates, 442 PageSize: 10, 443 }, &selected) 444 445 if err != nil { 446 return "", err 447 } 448 449 return fmt.Sprintf("%d", runs[selected].ID), nil 450 } 451 452 func GetRun(client *api.Client, repo ghrepo.Interface, runID string) (*Run, error) { 453 var result Run 454 455 path := fmt.Sprintf("repos/%s/actions/runs/%s?exclude_pull_requests=true", ghrepo.FullName(repo), runID) 456 457 err := client.REST(repo.RepoHost(), "GET", path, nil, &result) 458 if err != nil { 459 return nil, err 460 } 461 462 // Set name to workflow name 463 workflow, err := workflowShared.GetWorkflow(client, repo, result.WorkflowID) 464 if err != nil { 465 return nil, err 466 } else { 467 result.workflowName = workflow.Name 468 } 469 470 return &result, nil 471 } 472 473 type colorFunc func(string) string 474 475 func Symbol(cs *iostreams.ColorScheme, status Status, conclusion Conclusion) (string, colorFunc) { 476 noColor := func(s string) string { return s } 477 if status == Completed { 478 switch conclusion { 479 case Success: 480 return cs.SuccessIconWithColor(noColor), cs.Green 481 case Skipped, Neutral: 482 return "-", cs.Gray 483 default: 484 return cs.FailureIconWithColor(noColor), cs.Red 485 } 486 } 487 488 return "*", cs.Yellow 489 } 490 491 func PullRequestForRun(client *api.Client, repo ghrepo.Interface, run Run) (int, error) { 492 type response struct { 493 Repository struct { 494 PullRequests struct { 495 Nodes []struct { 496 Number int 497 HeadRepository struct { 498 Owner struct { 499 Login string 500 } 501 Name string 502 } 503 } 504 } 505 } 506 Number int 507 } 508 509 variables := map[string]interface{}{ 510 "owner": repo.RepoOwner(), 511 "repo": repo.RepoName(), 512 "headRefName": run.HeadBranch, 513 } 514 515 query := ` 516 query PullRequestForRun($owner: String!, $repo: String!, $headRefName: String!) { 517 repository(owner: $owner, name: $repo) { 518 pullRequests(headRefName: $headRefName, first: 1, orderBy: { field: CREATED_AT, direction: DESC }) { 519 nodes { 520 number 521 headRepository { 522 owner { 523 login 524 } 525 name 526 } 527 } 528 } 529 } 530 }` 531 532 var resp response 533 534 err := client.GraphQL(repo.RepoHost(), query, variables, &resp) 535 if err != nil { 536 return -1, err 537 } 538 539 prs := resp.Repository.PullRequests.Nodes 540 if len(prs) == 0 { 541 return -1, fmt.Errorf("no matching PR found for %s", run.HeadBranch) 542 } 543 544 number := -1 545 546 for _, pr := range prs { 547 if pr.HeadRepository.Owner.Login == run.HeadRepository.Owner.Login && pr.HeadRepository.Name == run.HeadRepository.Name { 548 number = pr.Number 549 } 550 } 551 552 if number == -1 { 553 return number, fmt.Errorf("no matching PR found for %s", run.HeadBranch) 554 } 555 556 return number, nil 557 } 558 559 func preciseAgo(now time.Time, createdAt time.Time) string { 560 ago := now.Sub(createdAt) 561 562 if ago < 30*24*time.Hour { 563 s := ago.Truncate(time.Second).String() 564 return fmt.Sprintf("%s ago", s) 565 } 566 567 return createdAt.Format("Jan _2, 2006") 568 }