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