github.com/cli/cli@v1.14.1-0.20210902173923-1af6a669e342/pkg/cmd/run/shared/shared.go (about) 1 package shared 2 3 import ( 4 "archive/zip" 5 "errors" 6 "fmt" 7 "net/url" 8 "strings" 9 "time" 10 11 "github.com/AlecAivazis/survey/v2" 12 "github.com/cli/cli/api" 13 "github.com/cli/cli/internal/ghrepo" 14 "github.com/cli/cli/pkg/iostreams" 15 "github.com/cli/cli/pkg/prompt" 16 ) 17 18 const ( 19 // Run statuses 20 Queued Status = "queued" 21 Completed Status = "completed" 22 InProgress Status = "in_progress" 23 Requested Status = "requested" 24 Waiting Status = "waiting" 25 26 // Run conclusions 27 ActionRequired Conclusion = "action_required" 28 Cancelled Conclusion = "cancelled" 29 Failure Conclusion = "failure" 30 Neutral Conclusion = "neutral" 31 Skipped Conclusion = "skipped" 32 Stale Conclusion = "stale" 33 StartupFailure Conclusion = "startup_failure" 34 Success Conclusion = "success" 35 TimedOut Conclusion = "timed_out" 36 37 AnnotationFailure Level = "failure" 38 AnnotationWarning Level = "warning" 39 ) 40 41 type Status string 42 type Conclusion string 43 type Level string 44 45 type Run struct { 46 Name string 47 CreatedAt time.Time `json:"created_at"` 48 UpdatedAt time.Time `json:"updated_at"` 49 Status Status 50 Conclusion Conclusion 51 Event string 52 ID int64 53 HeadBranch string `json:"head_branch"` 54 JobsURL string `json:"jobs_url"` 55 HeadCommit Commit `json:"head_commit"` 56 HeadSha string `json:"head_sha"` 57 URL string `json:"html_url"` 58 HeadRepository Repo `json:"head_repository"` 59 } 60 61 type Repo struct { 62 Owner struct { 63 Login string 64 } 65 Name string 66 } 67 68 type Commit struct { 69 Message string 70 } 71 72 func (r Run) CommitMsg() string { 73 commitLines := strings.Split(r.HeadCommit.Message, "\n") 74 if len(commitLines) > 0 { 75 return commitLines[0] 76 } else { 77 return r.HeadSha[0:8] 78 } 79 } 80 81 type Job struct { 82 ID int64 83 Status Status 84 Conclusion Conclusion 85 Name string 86 Steps Steps 87 StartedAt time.Time `json:"started_at"` 88 CompletedAt time.Time `json:"completed_at"` 89 URL string `json:"html_url"` 90 RunID int64 `json:"run_id"` 91 } 92 93 type Step struct { 94 Name string 95 Status Status 96 Conclusion Conclusion 97 Number int 98 Log *zip.File 99 } 100 101 type Steps []Step 102 103 func (s Steps) Len() int { return len(s) } 104 func (s Steps) Less(i, j int) bool { return s[i].Number < s[j].Number } 105 func (s Steps) Swap(i, j int) { s[i], s[j] = s[j], s[i] } 106 107 type Annotation struct { 108 JobName string 109 Message string 110 Path string 111 Level Level `json:"annotation_level"` 112 StartLine int `json:"start_line"` 113 } 114 115 func AnnotationSymbol(cs *iostreams.ColorScheme, a Annotation) string { 116 switch a.Level { 117 case AnnotationFailure: 118 return cs.FailureIcon() 119 case AnnotationWarning: 120 return cs.WarningIcon() 121 default: 122 return "-" 123 } 124 } 125 126 type CheckRun struct { 127 ID int64 128 } 129 130 func GetAnnotations(client *api.Client, repo ghrepo.Interface, job Job) ([]Annotation, error) { 131 var result []*Annotation 132 133 path := fmt.Sprintf("repos/%s/check-runs/%d/annotations", ghrepo.FullName(repo), job.ID) 134 135 err := client.REST(repo.RepoHost(), "GET", path, nil, &result) 136 if err != nil { 137 var httpError api.HTTPError 138 if errors.As(err, &httpError) && httpError.StatusCode == 404 { 139 return []Annotation{}, nil 140 } 141 return nil, err 142 } 143 144 out := []Annotation{} 145 146 for _, annotation := range result { 147 annotation.JobName = job.Name 148 out = append(out, *annotation) 149 } 150 151 return out, nil 152 } 153 154 func IsFailureState(c Conclusion) bool { 155 switch c { 156 case ActionRequired, Failure, StartupFailure, TimedOut: 157 return true 158 default: 159 return false 160 } 161 } 162 163 type RunsPayload struct { 164 TotalCount int `json:"total_count"` 165 WorkflowRuns []Run `json:"workflow_runs"` 166 } 167 168 func GetRunsWithFilter(client *api.Client, repo ghrepo.Interface, limit int, f func(Run) bool) ([]Run, error) { 169 path := fmt.Sprintf("repos/%s/actions/runs", ghrepo.FullName(repo)) 170 runs, err := getRuns(client, repo, path, 50) 171 if err != nil { 172 return nil, err 173 } 174 filtered := []Run{} 175 for _, run := range runs { 176 if f(run) { 177 filtered = append(filtered, run) 178 } 179 if len(filtered) == limit { 180 break 181 } 182 } 183 184 return filtered, nil 185 } 186 187 func GetRunsByWorkflow(client *api.Client, repo ghrepo.Interface, limit int, workflowID int64) ([]Run, error) { 188 path := fmt.Sprintf("repos/%s/actions/workflows/%d/runs", ghrepo.FullName(repo), workflowID) 189 return getRuns(client, repo, path, limit) 190 } 191 192 func GetRuns(client *api.Client, repo ghrepo.Interface, limit int) ([]Run, error) { 193 path := fmt.Sprintf("repos/%s/actions/runs", ghrepo.FullName(repo)) 194 return getRuns(client, repo, path, limit) 195 } 196 197 func getRuns(client *api.Client, repo ghrepo.Interface, path string, limit int) ([]Run, error) { 198 perPage := limit 199 page := 1 200 if limit > 100 { 201 perPage = 100 202 } 203 204 runs := []Run{} 205 206 for len(runs) < limit { 207 var result RunsPayload 208 209 parsed, err := url.Parse(path) 210 if err != nil { 211 return nil, err 212 } 213 query := parsed.Query() 214 query.Set("per_page", fmt.Sprintf("%d", perPage)) 215 query.Set("page", fmt.Sprintf("%d", page)) 216 parsed.RawQuery = query.Encode() 217 pagedPath := parsed.String() 218 219 err = client.REST(repo.RepoHost(), "GET", pagedPath, nil, &result) 220 if err != nil { 221 return nil, err 222 } 223 224 if len(result.WorkflowRuns) == 0 { 225 break 226 } 227 228 for _, run := range result.WorkflowRuns { 229 runs = append(runs, run) 230 if len(runs) == limit { 231 break 232 } 233 } 234 235 if len(result.WorkflowRuns) < perPage { 236 break 237 } 238 239 page++ 240 } 241 242 return runs, nil 243 } 244 245 type JobsPayload struct { 246 Jobs []Job 247 } 248 249 func GetJobs(client *api.Client, repo ghrepo.Interface, run Run) ([]Job, error) { 250 var result JobsPayload 251 if err := client.REST(repo.RepoHost(), "GET", run.JobsURL, nil, &result); err != nil { 252 return nil, err 253 } 254 return result.Jobs, nil 255 } 256 257 func PromptForRun(cs *iostreams.ColorScheme, runs []Run) (string, error) { 258 var selected int 259 now := time.Now() 260 261 candidates := []string{} 262 263 for _, run := range runs { 264 symbol, _ := Symbol(cs, run.Status, run.Conclusion) 265 candidates = append(candidates, 266 // TODO truncate commit message, long ones look terrible 267 fmt.Sprintf("%s %s, %s (%s) %s", symbol, run.CommitMsg(), run.Name, run.HeadBranch, preciseAgo(now, run.CreatedAt))) 268 } 269 270 // TODO consider custom filter so it's fuzzier. right now matches start anywhere in string but 271 // become contiguous 272 err := prompt.SurveyAskOne(&survey.Select{ 273 Message: "Select a workflow run", 274 Options: candidates, 275 PageSize: 10, 276 }, &selected) 277 278 if err != nil { 279 return "", err 280 } 281 282 return fmt.Sprintf("%d", runs[selected].ID), nil 283 } 284 285 func GetRun(client *api.Client, repo ghrepo.Interface, runID string) (*Run, error) { 286 var result Run 287 288 path := fmt.Sprintf("repos/%s/actions/runs/%s", ghrepo.FullName(repo), runID) 289 290 err := client.REST(repo.RepoHost(), "GET", path, nil, &result) 291 if err != nil { 292 return nil, err 293 } 294 295 return &result, nil 296 } 297 298 type colorFunc func(string) string 299 300 func Symbol(cs *iostreams.ColorScheme, status Status, conclusion Conclusion) (string, colorFunc) { 301 noColor := func(s string) string { return s } 302 if status == Completed { 303 switch conclusion { 304 case Success: 305 return cs.SuccessIconWithColor(noColor), cs.Green 306 case Skipped, Cancelled, Neutral: 307 return cs.SuccessIconWithColor(noColor), cs.Gray 308 default: 309 return cs.FailureIconWithColor(noColor), cs.Red 310 } 311 } 312 313 return "-", cs.Yellow 314 } 315 316 func PullRequestForRun(client *api.Client, repo ghrepo.Interface, run Run) (int, error) { 317 type response struct { 318 Repository struct { 319 PullRequests struct { 320 Nodes []struct { 321 Number int 322 HeadRepository struct { 323 Owner struct { 324 Login string 325 } 326 Name string 327 } 328 } 329 } 330 } 331 Number int 332 } 333 334 variables := map[string]interface{}{ 335 "owner": repo.RepoOwner(), 336 "repo": repo.RepoName(), 337 "headRefName": run.HeadBranch, 338 } 339 340 query := ` 341 query PullRequestForRun($owner: String!, $repo: String!, $headRefName: String!) { 342 repository(owner: $owner, name: $repo) { 343 pullRequests(headRefName: $headRefName, first: 1, orderBy: { field: CREATED_AT, direction: DESC }) { 344 nodes { 345 number 346 headRepository { 347 owner { 348 login 349 } 350 name 351 } 352 } 353 } 354 } 355 }` 356 357 var resp response 358 359 err := client.GraphQL(repo.RepoHost(), query, variables, &resp) 360 if err != nil { 361 return -1, err 362 } 363 364 prs := resp.Repository.PullRequests.Nodes 365 if len(prs) == 0 { 366 return -1, fmt.Errorf("no matching PR found for %s", run.HeadBranch) 367 } 368 369 number := -1 370 371 for _, pr := range prs { 372 if pr.HeadRepository.Owner.Login == run.HeadRepository.Owner.Login && pr.HeadRepository.Name == run.HeadRepository.Name { 373 number = pr.Number 374 } 375 } 376 377 if number == -1 { 378 return number, fmt.Errorf("no matching PR found for %s", run.HeadBranch) 379 } 380 381 return number, nil 382 } 383 384 func preciseAgo(now time.Time, createdAt time.Time) string { 385 ago := now.Sub(createdAt) 386 387 if ago < 30*24*time.Hour { 388 s := ago.Truncate(time.Second).String() 389 return fmt.Sprintf("%s ago", s) 390 } 391 392 return createdAt.Format("Jan _2, 2006") 393 }