Go · 35986 bytes Raw Blame History
1 // SPDX-License-Identifier: AGPL-3.0-or-later
2
3 package repo
4
5 import (
6 "bytes"
7 "context"
8 "errors"
9 "io"
10 "net/http"
11 "net/url"
12 "path"
13 "strconv"
14 "strings"
15 "time"
16
17 "github.com/go-chi/chi/v5"
18 "github.com/jackc/pgx/v5"
19 "github.com/jackc/pgx/v5/pgtype"
20
21 actionsdb "github.com/tenseleyFlow/shithub/internal/actions/sqlc"
22 "github.com/tenseleyFlow/shithub/internal/auth/policy"
23 "github.com/tenseleyFlow/shithub/internal/infra/storage"
24 )
25
26 const (
27 actionsRunsPageSize = int32(20)
28 actionsStepLogRenderLimit = 1 << 20
29 )
30
31 type actionsWorkflowView struct {
32 File string
33 Name string
34 Count int64
35 Href string
36 Active bool
37 }
38
39 type actionsListRunView struct {
40 ID int64
41 RunIndex int64
42 WorkflowFile string
43 WorkflowName string
44 Title string
45 HeadSha string
46 HeadShaShort string
47 HeadRef string
48 Event string
49 EventLabel string
50 ActorUsername string
51 StateText string
52 StateClass string
53 StateIcon string
54 CreatedAt time.Time
55 UpdatedAt time.Time
56 Duration string
57 Href string
58 }
59
60 type actionsListFilters struct {
61 Workflow string
62 Branch string
63 Event string
64 Status string
65 Conclusion string
66 Actor string
67 Page int32
68 HasAny bool
69 }
70
71 type actionsFilterOption struct {
72 Value string
73 Label string
74 Selected bool
75 }
76
77 type actionsPaginationView struct {
78 Page int32
79 PageSize int32
80 Total int64
81 Start int64
82 End int64
83 HasPrev bool
84 HasNext bool
85 PrevHref string
86 NextHref string
87 ResultText string
88 }
89
90 type actionsRunDetailView struct {
91 ID int64
92 RunIndex int64
93 WorkflowFile string
94 WorkflowName string
95 Title string
96 HeadSha string
97 HeadShaShort string
98 HeadRef string
99 Event string
100 EventLabel string
101 ActorUsername string
102 StateText string
103 StateClass string
104 StateIcon string
105 CreatedAt time.Time
106 UpdatedAt time.Time
107 Duration string
108 IsTerminal bool
109 StatusHref string
110 CancelHref string
111 CanCancel bool
112 ActionsHref string
113 CodeHref string
114 ArtifactCount int
115 JobCount int
116 CompletedCount int
117 FailureCount int
118 Jobs []actionsJobDetailView
119 Stages []actionsJobStageView
120 }
121
122 type actionsJobDetailView struct {
123 ID int64
124 JobIndex int32
125 JobKey string
126 Name string
127 RunsOn string
128 Needs []string
129 NeedsText string
130 StateText string
131 StateClass string
132 StateIcon string
133 Duration string
134 Anchor string
135 CancelHref string
136 CanCancel bool
137 // CancelRequested is true after a running job has been asked to stop but
138 // before its runner has reported the terminal cancelled state.
139 CancelRequested bool
140 IsCancellable bool
141 Depth int
142 Steps []actionsStepDetailView
143 }
144
145 type actionsStepDetailView struct {
146 ID int64
147 StepIndex int32
148 StepID string
149 Name string
150 Kind string
151 Detail string
152 StateText string
153 StateClass string
154 StateIcon string
155 Duration string
156 IsTerminal bool
157 LogByteCount int64
158 LogHref string
159 }
160
161 type actionsJobStageView struct {
162 Index int
163 Jobs []actionsJobDetailView
164 }
165
166 type actionsStepLogView struct {
167 Run actionsRunDetailView
168 Job actionsJobDetailView
169 Step actionsStepDetailView
170 LogText string
171 LogSource string
172 LogError string
173 LogTruncated bool
174 StreamHref string
175 DownloadURL string
176 BackHref string
177 }
178
179 func (h *Handlers) repoTabActions(w http.ResponseWriter, r *http.Request) {
180 row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionRepoRead)
181 if !ok {
182 return
183 }
184
185 filters := actionsListFiltersFromRequest(r)
186 q := actionsdb.New()
187 params := workflowRunListParams(row.ID, filters)
188 params.PageLimit = actionsRunsPageSize
189 params.PageOffset = (filters.Page - 1) * actionsRunsPageSize
190
191 runs, err := q.ListWorkflowRunsForRepo(r.Context(), h.d.Pool, params)
192 if err != nil {
193 h.d.Logger.WarnContext(r.Context(), "repo actions: list workflow runs", "repo_id", row.ID, "error", err)
194 h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
195 return
196 }
197 filteredCount, err := q.CountWorkflowRunsForRepo(r.Context(), h.d.Pool, workflowRunCountParams(row.ID, filters))
198 if err != nil {
199 h.d.Logger.WarnContext(r.Context(), "repo actions: count workflow runs", "repo_id", row.ID, "error", err)
200 h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
201 return
202 }
203 workflowRows, err := q.ListWorkflowRunWorkflowsForRepo(r.Context(), h.d.Pool, row.ID)
204 if err != nil {
205 h.d.Logger.WarnContext(r.Context(), "repo actions: list workflows", "repo_id", row.ID, "error", err)
206 h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
207 return
208 }
209
210 basePath := "/" + owner.Username + "/" + row.Name + "/actions"
211 workflows, allRunCount, activeWorkflowName := actionsWorkflowViews(workflowRows, filters, basePath)
212 runViews := make([]actionsListRunView, 0, len(runs))
213 now := time.Now()
214 for _, run := range runs {
215 runViews = append(runViews, actionsListRunViewFromRow(run, owner.Username, row.Name, now))
216 }
217 dispatchWorkflows, err := h.actionsDispatchWorkflowViews(r.Context(), row, owner.Username)
218 if err != nil {
219 h.d.Logger.WarnContext(r.Context(), "repo actions: discover dispatch workflows", "repo_id", row.ID, "error", err)
220 }
221
222 data := h.repoHeaderData(r, row, owner.Username, "actions")
223 data["Title"] = "Actions · " + row.Name
224 data["Runs"] = runViews
225 data["Workflows"] = workflows
226 data["DispatchWorkflows"] = dispatchWorkflows
227 data["RunCount"] = allRunCount
228 data["FilteredRunCount"] = filteredCount
229 data["ActiveWorkflowName"] = activeWorkflowName
230 data["Filters"] = filters
231 data["EventOptions"] = actionsEventOptions(filters.Event)
232 data["StatusOptions"] = actionsStatusOptions(filters.Status)
233 data["ConclusionOptions"] = actionsConclusionOptions(filters.Conclusion)
234 data["Pagination"] = actionsPagination(basePath, filters, filteredCount, int64(len(runViews)))
235 if err := h.d.Render.RenderPage(w, r, "repo/actions", data); err != nil {
236 h.d.Logger.ErrorContext(r.Context(), "repo actions render", "error", err)
237 }
238 }
239
240 func actionsListFiltersFromRequest(r *http.Request) actionsListFilters {
241 q := r.URL.Query()
242 f := actionsListFilters{
243 Workflow: trimFilter(q.Get("workflow"), 256),
244 Branch: trimFilter(q.Get("branch"), 256),
245 Event: validWorkflowRunEvent(q.Get("event")),
246 Status: validWorkflowRunStatus(q.Get("status")),
247 Conclusion: validWorkflowRunConclusion(q.Get("conclusion")),
248 Actor: trimFilter(q.Get("actor"), 39),
249 Page: parseActionsPage(q.Get("page")),
250 }
251 f.HasAny = f.Workflow != "" || f.Branch != "" || f.Event != "" || f.Status != "" || f.Conclusion != "" || f.Actor != ""
252 return f
253 }
254
255 func trimFilter(v string, max int) string {
256 v = strings.TrimSpace(v)
257 if len(v) > max {
258 return v[:max]
259 }
260 return v
261 }
262
263 func parseActionsPage(v string) int32 {
264 page, err := strconv.ParseInt(strings.TrimSpace(v), 10, 32)
265 if err != nil || page < 1 {
266 return 1
267 }
268 if page > 100000 {
269 return 100000
270 }
271 return int32(page)
272 }
273
274 func workflowRunListParams(repoID int64, filters actionsListFilters) actionsdb.ListWorkflowRunsForRepoParams {
275 return actionsdb.ListWorkflowRunsForRepoParams{
276 RepoID: repoID,
277 WorkflowFile: nullableText(filters.Workflow),
278 HeadRef: nullableText(filters.Branch),
279 Event: nullableWorkflowRunEvent(filters.Event),
280 Status: nullableWorkflowRunStatus(filters.Status),
281 Conclusion: nullableWorkflowRunConclusion(filters.Conclusion),
282 ActorUsername: nullableText(filters.Actor),
283 }
284 }
285
286 func workflowRunCountParams(repoID int64, filters actionsListFilters) actionsdb.CountWorkflowRunsForRepoParams {
287 return actionsdb.CountWorkflowRunsForRepoParams{
288 RepoID: repoID,
289 WorkflowFile: nullableText(filters.Workflow),
290 HeadRef: nullableText(filters.Branch),
291 Event: nullableWorkflowRunEvent(filters.Event),
292 Status: nullableWorkflowRunStatus(filters.Status),
293 Conclusion: nullableWorkflowRunConclusion(filters.Conclusion),
294 ActorUsername: nullableText(filters.Actor),
295 }
296 }
297
298 func nullableText(v string) pgtype.Text {
299 if v == "" {
300 return pgtype.Text{}
301 }
302 return pgtype.Text{String: v, Valid: true}
303 }
304
305 func nullableWorkflowRunEvent(v string) actionsdb.NullWorkflowRunEvent {
306 if v == "" {
307 return actionsdb.NullWorkflowRunEvent{}
308 }
309 return actionsdb.NullWorkflowRunEvent{WorkflowRunEvent: actionsdb.WorkflowRunEvent(v), Valid: true}
310 }
311
312 func nullableWorkflowRunStatus(v string) actionsdb.NullWorkflowRunStatus {
313 if v == "" {
314 return actionsdb.NullWorkflowRunStatus{}
315 }
316 return actionsdb.NullWorkflowRunStatus{WorkflowRunStatus: actionsdb.WorkflowRunStatus(v), Valid: true}
317 }
318
319 func nullableWorkflowRunConclusion(v string) actionsdb.NullCheckConclusion {
320 if v == "" {
321 return actionsdb.NullCheckConclusion{}
322 }
323 return actionsdb.NullCheckConclusion{CheckConclusion: actionsdb.CheckConclusion(v), Valid: true}
324 }
325
326 func actionsWorkflowViews(rows []actionsdb.ListWorkflowRunWorkflowsForRepoRow, filters actionsListFilters, basePath string) ([]actionsWorkflowView, int64, string) {
327 params := actionsFilterParams(filters)
328 params.Del("page")
329 out := make([]actionsWorkflowView, 0, len(rows))
330 var total int64
331 activeName := ""
332 for _, row := range rows {
333 total += row.RunCount
334 name := workflowDisplayName(row.WorkflowName, row.WorkflowFile)
335 p := cloneValues(params)
336 p.Set("workflow", row.WorkflowFile)
337 active := filters.Workflow == row.WorkflowFile
338 if active {
339 activeName = name
340 }
341 out = append(out, actionsWorkflowView{
342 File: row.WorkflowFile,
343 Name: name,
344 Count: row.RunCount,
345 Href: pathWithQuery(basePath, p),
346 Active: active,
347 })
348 }
349 return out, total, activeName
350 }
351
352 func actionsListRunViewFromRow(row actionsdb.ListWorkflowRunsForRepoRow, owner, repoName string, now time.Time) actionsListRunView {
353 stateText, stateClass, stateIcon := workflowRunState(row.Status, row.Conclusion)
354 title := workflowDisplayName(row.WorkflowName, row.WorkflowFile)
355 updatedAt := row.UpdatedAt.Time
356 if updatedAt.IsZero() {
357 updatedAt = row.CreatedAt.Time
358 }
359 return actionsListRunView{
360 ID: row.ID,
361 RunIndex: row.RunIndex,
362 WorkflowFile: row.WorkflowFile,
363 WorkflowName: row.WorkflowName,
364 Title: title,
365 HeadSha: row.HeadSha,
366 HeadShaShort: shortSHA(row.HeadSha),
367 HeadRef: row.HeadRef,
368 Event: string(row.Event),
369 EventLabel: workflowRunEventLabel(string(row.Event)),
370 ActorUsername: row.ActorUsername,
371 StateText: stateText,
372 StateClass: stateClass,
373 StateIcon: stateIcon,
374 CreatedAt: row.CreatedAt.Time,
375 UpdatedAt: updatedAt,
376 Duration: workflowRunDuration(row.Status, row.StartedAt, row.CompletedAt, row.CreatedAt, updatedAt, now),
377 Href: "/" + owner + "/" + repoName + "/actions/runs/" + strconv.FormatInt(row.RunIndex, 10),
378 }
379 }
380
381 func workflowDisplayName(name, file string) string {
382 name = strings.TrimSpace(name)
383 if name != "" {
384 return name
385 }
386 base := path.Base(file)
387 ext := path.Ext(base)
388 if ext != "" {
389 base = strings.TrimSuffix(base, ext)
390 }
391 if base == "." || base == "/" || base == "" {
392 return file
393 }
394 return base
395 }
396
397 func workflowRunState(status actionsdb.WorkflowRunStatus, conclusion actionsdb.NullCheckConclusion) (string, string, string) {
398 switch status {
399 case actionsdb.WorkflowRunStatusQueued:
400 return "Queued", "pending", "dot-fill"
401 case actionsdb.WorkflowRunStatusRunning:
402 return "In progress", "running", "dot-fill"
403 case actionsdb.WorkflowRunStatusCancelled:
404 return "Cancelled", "neutral", "x-circle"
405 case actionsdb.WorkflowRunStatusCompleted:
406 if !conclusion.Valid {
407 return "Completed", "neutral", "check-circle"
408 }
409 default:
410 if !conclusion.Valid {
411 return string(status), "neutral", "dot-fill"
412 }
413 }
414 switch conclusion.CheckConclusion {
415 case actionsdb.CheckConclusionSuccess, actionsdb.CheckConclusionSkipped, actionsdb.CheckConclusionNeutral:
416 return "Success", "success", "check-circle-fill"
417 case actionsdb.CheckConclusionFailure, actionsdb.CheckConclusionTimedOut, actionsdb.CheckConclusionActionRequired:
418 return "Failure", "failure", "x-circle-fill"
419 case actionsdb.CheckConclusionCancelled, actionsdb.CheckConclusionStale:
420 return "Cancelled", "neutral", "x-circle"
421 default:
422 return string(conclusion.CheckConclusion), "neutral", "dot-fill"
423 }
424 }
425
426 func workflowRunDuration(status actionsdb.WorkflowRunStatus, startedAt, completedAt, createdAt pgtype.Timestamptz, updatedAt, now time.Time) string {
427 if status == actionsdb.WorkflowRunStatusQueued {
428 return "—"
429 }
430 start := createdAt.Time
431 if startedAt.Valid {
432 start = startedAt.Time
433 }
434 end := updatedAt
435 if status == actionsdb.WorkflowRunStatusRunning {
436 end = now
437 } else if completedAt.Valid {
438 end = completedAt.Time
439 }
440 return formatDuration(end.Sub(start))
441 }
442
443 func validWorkflowRunEvent(v string) string {
444 switch strings.TrimSpace(v) {
445 case "push", "pull_request", "schedule", "workflow_dispatch":
446 return strings.TrimSpace(v)
447 default:
448 return ""
449 }
450 }
451
452 func validWorkflowRunStatus(v string) string {
453 switch strings.TrimSpace(v) {
454 case "queued", "running", "completed", "cancelled":
455 return strings.TrimSpace(v)
456 default:
457 return ""
458 }
459 }
460
461 func validWorkflowRunConclusion(v string) string {
462 switch strings.TrimSpace(v) {
463 case "success", "failure", "neutral", "cancelled", "skipped", "timed_out", "action_required", "stale":
464 return strings.TrimSpace(v)
465 default:
466 return ""
467 }
468 }
469
470 func actionsEventOptions(selected string) []actionsFilterOption {
471 return selectedOptions(selected, []actionsFilterOption{
472 {Value: "", Label: "Any event"},
473 {Value: "push", Label: "push"},
474 {Value: "pull_request", Label: "pull_request"},
475 {Value: "schedule", Label: "schedule"},
476 {Value: "workflow_dispatch", Label: "workflow_dispatch"},
477 })
478 }
479
480 func actionsStatusOptions(selected string) []actionsFilterOption {
481 return selectedOptions(selected, []actionsFilterOption{
482 {Value: "", Label: "Any status"},
483 {Value: "queued", Label: "queued"},
484 {Value: "running", Label: "running"},
485 {Value: "completed", Label: "completed"},
486 {Value: "cancelled", Label: "cancelled"},
487 })
488 }
489
490 func actionsConclusionOptions(selected string) []actionsFilterOption {
491 return selectedOptions(selected, []actionsFilterOption{
492 {Value: "", Label: "Any conclusion"},
493 {Value: "success", Label: "success"},
494 {Value: "failure", Label: "failure"},
495 {Value: "neutral", Label: "neutral"},
496 {Value: "cancelled", Label: "cancelled"},
497 {Value: "skipped", Label: "skipped"},
498 {Value: "timed_out", Label: "timed_out"},
499 {Value: "action_required", Label: "action_required"},
500 {Value: "stale", Label: "stale"},
501 })
502 }
503
504 func selectedOptions(selected string, opts []actionsFilterOption) []actionsFilterOption {
505 out := make([]actionsFilterOption, len(opts))
506 copy(out, opts)
507 for i := range out {
508 out[i].Selected = out[i].Value == selected
509 }
510 return out
511 }
512
513 func workflowRunEventLabel(v string) string {
514 switch v {
515 case "pull_request":
516 return "pull request"
517 case "workflow_dispatch":
518 return "workflow dispatch"
519 default:
520 return v
521 }
522 }
523
524 func actionsPagination(basePath string, filters actionsListFilters, total, pageRows int64) actionsPaginationView {
525 offset := int64((filters.Page - 1) * actionsRunsPageSize)
526 view := actionsPaginationView{
527 Page: filters.Page,
528 PageSize: actionsRunsPageSize,
529 Total: total,
530 HasPrev: filters.Page > 1,
531 HasNext: offset+pageRows < total,
532 }
533 if total == 0 {
534 view.ResultText = "No workflow runs"
535 return view
536 }
537 view.Start = offset + 1
538 view.End = offset + pageRows
539 view.ResultText = strconv.FormatInt(view.Start, 10) + "-" + strconv.FormatInt(view.End, 10) + " of " + strconv.FormatInt(total, 10)
540 if view.HasPrev {
541 p := actionsFilterParams(filters)
542 if filters.Page <= 2 {
543 p.Del("page")
544 } else {
545 p.Set("page", strconv.FormatInt(int64(filters.Page-1), 10))
546 }
547 view.PrevHref = pathWithQuery(basePath, p)
548 }
549 if view.HasNext {
550 p := actionsFilterParams(filters)
551 p.Set("page", strconv.FormatInt(int64(filters.Page+1), 10))
552 view.NextHref = pathWithQuery(basePath, p)
553 }
554 return view
555 }
556
557 func actionsFilterParams(filters actionsListFilters) url.Values {
558 v := url.Values{}
559 if filters.Workflow != "" {
560 v.Set("workflow", filters.Workflow)
561 }
562 if filters.Branch != "" {
563 v.Set("branch", filters.Branch)
564 }
565 if filters.Event != "" {
566 v.Set("event", filters.Event)
567 }
568 if filters.Status != "" {
569 v.Set("status", filters.Status)
570 }
571 if filters.Conclusion != "" {
572 v.Set("conclusion", filters.Conclusion)
573 }
574 if filters.Actor != "" {
575 v.Set("actor", filters.Actor)
576 }
577 if filters.Page > 1 {
578 v.Set("page", strconv.FormatInt(int64(filters.Page), 10))
579 }
580 return v
581 }
582
583 func cloneValues(v url.Values) url.Values {
584 out := url.Values{}
585 for key, values := range v {
586 for _, value := range values {
587 out.Add(key, value)
588 }
589 }
590 return out
591 }
592
593 func pathWithQuery(basePath string, q url.Values) string {
594 if encoded := q.Encode(); encoded != "" {
595 return basePath + "?" + encoded
596 }
597 return basePath
598 }
599
600 func (h *Handlers) repoActionRun(w http.ResponseWriter, r *http.Request) {
601 row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionRepoRead)
602 if !ok {
603 return
604 }
605 runIndex, ok := parsePositiveInt64Param(r, "runIndex")
606 if !ok {
607 h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
608 return
609 }
610 view, err := h.loadActionsRunDetail(r.Context(), row.ID, owner.Username, row.Name, runIndex)
611 if err != nil {
612 if errors.Is(err, pgx.ErrNoRows) {
613 h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
614 } else {
615 h.d.Logger.WarnContext(r.Context(), "repo actions: get run detail", "repo_id", row.ID, "run_index", runIndex, "error", err)
616 h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
617 }
618 return
619 }
620 h.applyActionsCancelControls(r, row, &view)
621
622 data := h.repoHeaderData(r, row, owner.Username, "actions")
623 data["Title"] = view.Title + " #" + strconv.FormatInt(view.RunIndex, 10) + " · " + row.Name
624 data["Run"] = view
625 data["UseHTMX"] = true
626 if err := h.d.Render.RenderPage(w, r, "repo/action_run", data); err != nil {
627 h.d.Logger.ErrorContext(r.Context(), "repo action run render", "run_index", runIndex, "error", err)
628 }
629 }
630
631 func (h *Handlers) repoActionRunStatus(w http.ResponseWriter, r *http.Request) {
632 row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionRepoRead)
633 if !ok {
634 return
635 }
636 runIndex, ok := parsePositiveInt64Param(r, "runIndex")
637 if !ok {
638 h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
639 return
640 }
641 view, err := h.loadActionsRunDetail(r.Context(), row.ID, owner.Username, row.Name, runIndex)
642 if err != nil {
643 if errors.Is(err, pgx.ErrNoRows) {
644 h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
645 } else {
646 h.d.Logger.WarnContext(r.Context(), "repo actions: get run status", "repo_id", row.ID, "run_index", runIndex, "error", err)
647 h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
648 }
649 return
650 }
651
652 data := h.repoHeaderData(r, row, owner.Username, "actions")
653 data["Run"] = view
654 if err := h.d.Render.RenderFragment(w, "repo/action_run_status", data); err != nil {
655 h.d.Logger.ErrorContext(r.Context(), "repo action run status render", "run_index", runIndex, "error", err)
656 }
657 }
658
659 func (h *Handlers) repoActionStepLog(w http.ResponseWriter, r *http.Request) {
660 row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionRepoRead)
661 if !ok {
662 return
663 }
664 runIndex, ok := parsePositiveInt64Param(r, "runIndex")
665 if !ok {
666 h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
667 return
668 }
669 jobIndex, ok := parseNonNegativeInt32Param(r, "jobIndex")
670 if !ok {
671 h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
672 return
673 }
674 stepIndex, ok := parseNonNegativeInt32Param(r, "stepIndex")
675 if !ok {
676 h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
677 return
678 }
679 run, err := h.loadActionsRunDetail(r.Context(), row.ID, owner.Username, row.Name, runIndex)
680 if err != nil {
681 if errors.Is(err, pgx.ErrNoRows) {
682 h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
683 } else {
684 h.d.Logger.WarnContext(r.Context(), "repo actions: get run for step log", "repo_id", row.ID, "run_index", runIndex, "error", err)
685 h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
686 }
687 return
688 }
689
690 job, step, ok := findActionStep(run, jobIndex, stepIndex)
691 if !ok {
692 h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
693 return
694 }
695 logContent, err := h.loadStepLogContent(r.Context(), step.ID)
696 if err != nil {
697 h.d.Logger.WarnContext(r.Context(), "repo actions: load step log", "step_id", step.ID, "error", err)
698 h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
699 return
700 }
701
702 view := actionsStepLogView{
703 Run: run,
704 Job: job,
705 Step: step,
706 LogText: logContent.Text,
707 LogSource: logContent.Source,
708 LogError: logContent.Error,
709 LogTruncated: logContent.Truncated,
710 DownloadURL: logContent.DownloadURL,
711 BackHref: run.ActionsHref + "/runs/" + strconv.FormatInt(run.RunIndex, 10) + "#job-" + strconv.FormatInt(int64(job.JobIndex), 10),
712 }
713 if !step.IsTerminal && logContent.Error == "" && logContent.DownloadURL == "" {
714 view.StreamHref = step.LogHref + "/log/stream?after=" + strconv.FormatInt(int64(logContent.LastSeq), 10)
715 }
716 data := h.repoHeaderData(r, row, owner.Username, "actions")
717 data["Title"] = step.Name + " · " + run.Title + " #" + strconv.FormatInt(run.RunIndex, 10)
718 data["Log"] = view
719 if err := h.d.Render.RenderPage(w, r, "repo/action_step_log", data); err != nil {
720 h.d.Logger.ErrorContext(r.Context(), "repo action step log render", "run_index", runIndex, "job_index", jobIndex, "step_index", stepIndex, "error", err)
721 }
722 }
723
724 func (h *Handlers) loadActionsRunDetail(ctx context.Context, repoID int64, owner, repoName string, runIndex int64) (actionsRunDetailView, error) {
725 q := actionsdb.New()
726 run, err := q.GetWorkflowRunForRepoByIndex(ctx, h.d.Pool, actionsdb.GetWorkflowRunForRepoByIndexParams{
727 RepoID: repoID,
728 RunIndex: runIndex,
729 })
730 if err != nil {
731 return actionsRunDetailView{}, err
732 }
733 jobs, err := q.ListJobsForRun(ctx, h.d.Pool, run.ID)
734 if err != nil {
735 return actionsRunDetailView{}, err
736 }
737 artifacts, err := q.ListArtifactsForRun(ctx, h.d.Pool, run.ID)
738 if err != nil {
739 return actionsRunDetailView{}, err
740 }
741
742 basePath := "/" + owner + "/" + repoName + "/actions"
743 runPath := basePath + "/runs/" + strconv.FormatInt(run.RunIndex, 10)
744 now := time.Now()
745 stateText, stateClass, stateIcon := workflowRunState(run.Status, run.Conclusion)
746 updatedAt := pgTime(run.UpdatedAt, run.CreatedAt.Time)
747 view := actionsRunDetailView{
748 ID: run.ID,
749 RunIndex: run.RunIndex,
750 WorkflowFile: run.WorkflowFile,
751 WorkflowName: run.WorkflowName,
752 Title: workflowDisplayName(run.WorkflowName, run.WorkflowFile),
753 HeadSha: run.HeadSha,
754 HeadShaShort: shortSHA(run.HeadSha),
755 HeadRef: run.HeadRef,
756 Event: string(run.Event),
757 EventLabel: workflowRunEventLabel(string(run.Event)),
758 ActorUsername: run.ActorUsername,
759 StateText: stateText,
760 StateClass: stateClass,
761 StateIcon: stateIcon,
762 CreatedAt: run.CreatedAt.Time,
763 UpdatedAt: updatedAt,
764 Duration: workflowRunDuration(run.Status, run.StartedAt, run.CompletedAt, run.CreatedAt, updatedAt, now),
765 IsTerminal: workflowRunTerminal(run.Status),
766 StatusHref: runPath + "/status",
767 CancelHref: runPath + "/cancel",
768 ActionsHref: basePath,
769 CodeHref: "/" + owner + "/" + repoName + "/tree/" + codeTarget(run.HeadRef, run.HeadSha),
770 ArtifactCount: len(artifacts),
771 JobCount: len(jobs),
772 CompletedCount: 0,
773 FailureCount: 0,
774 Jobs: make([]actionsJobDetailView, 0, len(jobs)),
775 }
776 for _, job := range jobs {
777 steps, err := q.ListStepsForJob(ctx, h.d.Pool, job.ID)
778 if err != nil {
779 return actionsRunDetailView{}, err
780 }
781 jobView := actionsJobDetailViewFromRow(job, owner, repoName, run.RunIndex, now)
782 jobView.Steps = make([]actionsStepDetailView, 0, len(steps))
783 for _, step := range steps {
784 jobView.Steps = append(jobView.Steps, actionsStepDetailViewFromRow(step, owner, repoName, run.RunIndex, job.JobIndex, now))
785 }
786 if job.Status == actionsdb.WorkflowJobStatusCompleted || job.Status == actionsdb.WorkflowJobStatusCancelled || job.Status == actionsdb.WorkflowJobStatusSkipped {
787 view.CompletedCount++
788 }
789 if jobView.StateClass == "failure" {
790 view.FailureCount++
791 }
792 view.Jobs = append(view.Jobs, jobView)
793 }
794 view.Stages = actionsJobStages(view.Jobs)
795 return view, nil
796 }
797
798 func actionsJobDetailViewFromRow(row actionsdb.ListJobsForRunRow, owner, repoName string, runIndex int64, now time.Time) actionsJobDetailView {
799 stateText, stateClass, stateIcon := workflowJobState(row.Status, row.Conclusion)
800 name := strings.TrimSpace(row.JobName)
801 if name == "" {
802 name = row.JobKey
803 }
804 return actionsJobDetailView{
805 ID: row.ID,
806 JobIndex: row.JobIndex,
807 JobKey: row.JobKey,
808 Name: name,
809 RunsOn: row.RunsOn,
810 Needs: append([]string(nil), row.NeedsJobs...),
811 NeedsText: strings.Join(row.NeedsJobs, ", "),
812 StateText: stateText,
813 StateClass: stateClass,
814 StateIcon: stateIcon,
815 Duration: actionItemDuration(string(row.Status), string(actionsdb.WorkflowJobStatusQueued), row.StartedAt, row.CompletedAt, row.CreatedAt, row.UpdatedAt, now),
816 Anchor: "job-" + strconv.FormatInt(int64(row.JobIndex), 10),
817 CancelHref: "/" + owner + "/" + repoName + "/actions/runs/" + strconv.FormatInt(runIndex, 10) +
818 "/jobs/" + strconv.FormatInt(int64(row.JobIndex), 10) + "/cancel",
819 CancelRequested: row.CancelRequested,
820 IsCancellable: row.Status == actionsdb.WorkflowJobStatusQueued ||
821 row.Status == actionsdb.WorkflowJobStatusRunning,
822 }
823 }
824
825 func actionsStepDetailViewFromRow(row actionsdb.ListStepsForJobRow, owner, repoName string, runIndex int64, jobIndex int32, now time.Time) actionsStepDetailView {
826 stateText, stateClass, stateIcon := workflowStepState(row.Status, row.Conclusion)
827 name, kind, detail := workflowStepDisplay(row)
828 return actionsStepDetailView{
829 ID: row.ID,
830 StepIndex: row.StepIndex,
831 StepID: row.StepID,
832 Name: name,
833 Kind: kind,
834 Detail: detail,
835 StateText: stateText,
836 StateClass: stateClass,
837 StateIcon: stateIcon,
838 Duration: actionItemDuration(string(row.Status), string(actionsdb.WorkflowStepStatusQueued), row.StartedAt, row.CompletedAt, row.CreatedAt, row.UpdatedAt, now),
839 IsTerminal: workflowStepTerminal(row.Status),
840 LogByteCount: row.LogByteCount,
841 LogHref: "/" + owner + "/" + repoName + "/actions/runs/" + strconv.FormatInt(runIndex, 10) +
842 "/jobs/" + strconv.FormatInt(int64(jobIndex), 10) +
843 "/steps/" + strconv.FormatInt(int64(row.StepIndex), 10),
844 }
845 }
846
847 func workflowStepDisplay(row actionsdb.ListStepsForJobRow) (name, kind, detail string) {
848 if row.UsesAlias != "" {
849 kind = "uses"
850 detail = row.UsesAlias
851 } else {
852 kind = "run"
853 detail = firstCommandLine(row.RunCommand)
854 }
855 name = strings.TrimSpace(row.StepName)
856 if name == "" {
857 name = strings.TrimSpace(detail)
858 }
859 if name == "" {
860 name = "Step " + strconv.Itoa(int(row.StepIndex)+1)
861 }
862 if len(detail) > 120 {
863 detail = detail[:117] + "..."
864 }
865 return name, kind, detail
866 }
867
868 func firstCommandLine(command string) string {
869 for _, line := range strings.Split(command, "\n") {
870 line = strings.TrimSpace(line)
871 if line != "" {
872 return line
873 }
874 }
875 return ""
876 }
877
878 func actionsJobStages(jobs []actionsJobDetailView) []actionsJobStageView {
879 indexByKey := make(map[string]int, len(jobs))
880 for i := range jobs {
881 indexByKey[jobs[i].JobKey] = i
882 }
883 state := make(map[string]int, len(jobs))
884 var depthFor func(string) int
885 depthFor = func(key string) int {
886 i, ok := indexByKey[key]
887 if !ok {
888 return 0
889 }
890 switch state[key] {
891 case 1:
892 return 0
893 case 2:
894 return jobs[i].Depth
895 }
896 state[key] = 1
897 depth := 0
898 for _, need := range jobs[i].Needs {
899 if _, ok := indexByKey[need]; ok {
900 if depDepth := depthFor(need) + 1; depDepth > depth {
901 depth = depDepth
902 }
903 }
904 }
905 jobs[i].Depth = depth
906 state[key] = 2
907 return depth
908 }
909 maxDepth := 0
910 for i := range jobs {
911 depth := depthFor(jobs[i].JobKey)
912 if depth > maxDepth {
913 maxDepth = depth
914 }
915 }
916 stages := make([]actionsJobStageView, maxDepth+1)
917 for i := range stages {
918 stages[i].Index = i
919 }
920 for _, job := range jobs {
921 stages[job.Depth].Jobs = append(stages[job.Depth].Jobs, job)
922 }
923 return stages
924 }
925
926 func workflowJobState(status actionsdb.WorkflowJobStatus, conclusion actionsdb.NullCheckConclusion) (string, string, string) {
927 if status == actionsdb.WorkflowJobStatusSkipped && !conclusion.Valid {
928 return "Skipped", "neutral", "dash"
929 }
930 if status == actionsdb.WorkflowJobStatusCompleted && conclusion.Valid {
931 return workflowConclusionState(conclusion.CheckConclusion)
932 }
933 switch status {
934 case actionsdb.WorkflowJobStatusQueued:
935 return "Queued", "pending", "dot-fill"
936 case actionsdb.WorkflowJobStatusRunning:
937 return "In progress", "running", "dot-fill"
938 case actionsdb.WorkflowJobStatusCancelled:
939 return "Cancelled", "neutral", "x-circle"
940 case actionsdb.WorkflowJobStatusCompleted:
941 return "Completed", "neutral", "check-circle"
942 default:
943 if conclusion.Valid {
944 return workflowConclusionState(conclusion.CheckConclusion)
945 }
946 return string(status), "neutral", "dot-fill"
947 }
948 }
949
950 func workflowStepState(status actionsdb.WorkflowStepStatus, conclusion actionsdb.NullCheckConclusion) (string, string, string) {
951 if status == actionsdb.WorkflowStepStatusSkipped && !conclusion.Valid {
952 return "Skipped", "neutral", "dash"
953 }
954 if status == actionsdb.WorkflowStepStatusCompleted && conclusion.Valid {
955 return workflowConclusionState(conclusion.CheckConclusion)
956 }
957 switch status {
958 case actionsdb.WorkflowStepStatusQueued:
959 return "Queued", "pending", "dot-fill"
960 case actionsdb.WorkflowStepStatusRunning:
961 return "In progress", "running", "dot-fill"
962 case actionsdb.WorkflowStepStatusCancelled:
963 return "Cancelled", "neutral", "x-circle"
964 case actionsdb.WorkflowStepStatusCompleted:
965 return "Completed", "neutral", "check-circle"
966 default:
967 if conclusion.Valid {
968 return workflowConclusionState(conclusion.CheckConclusion)
969 }
970 return string(status), "neutral", "dot-fill"
971 }
972 }
973
974 func workflowConclusionState(conclusion actionsdb.CheckConclusion) (string, string, string) {
975 switch conclusion {
976 case actionsdb.CheckConclusionSuccess, actionsdb.CheckConclusionSkipped, actionsdb.CheckConclusionNeutral:
977 return "Success", "success", "check-circle-fill"
978 case actionsdb.CheckConclusionFailure, actionsdb.CheckConclusionTimedOut, actionsdb.CheckConclusionActionRequired:
979 return "Failure", "failure", "x-circle-fill"
980 case actionsdb.CheckConclusionCancelled, actionsdb.CheckConclusionStale:
981 return "Cancelled", "neutral", "x-circle"
982 default:
983 return string(conclusion), "neutral", "dot-fill"
984 }
985 }
986
987 func workflowRunTerminal(status actionsdb.WorkflowRunStatus) bool {
988 return status == actionsdb.WorkflowRunStatusCompleted || status == actionsdb.WorkflowRunStatusCancelled
989 }
990
991 func workflowStepTerminal(status actionsdb.WorkflowStepStatus) bool {
992 return status == actionsdb.WorkflowStepStatusCompleted ||
993 status == actionsdb.WorkflowStepStatusCancelled ||
994 status == actionsdb.WorkflowStepStatusSkipped
995 }
996
997 func actionItemDuration(status string, queuedStatus string, startedAt, completedAt, createdAt, updatedAt pgtype.Timestamptz, now time.Time) string {
998 if status == queuedStatus {
999 return "—"
1000 }
1001 start := createdAt.Time
1002 if startedAt.Valid {
1003 start = startedAt.Time
1004 }
1005 end := pgTime(updatedAt, now)
1006 if status == "running" {
1007 end = now
1008 } else if completedAt.Valid {
1009 end = completedAt.Time
1010 }
1011 return formatDuration(end.Sub(start))
1012 }
1013
1014 func pgTime(ts pgtype.Timestamptz, fallback time.Time) time.Time {
1015 if ts.Valid && !ts.Time.IsZero() {
1016 return ts.Time
1017 }
1018 return fallback
1019 }
1020
1021 func codeTarget(ref, sha string) string {
1022 if ref != "" {
1023 return ref
1024 }
1025 return sha
1026 }
1027
1028 func findActionStep(run actionsRunDetailView, jobIndex, stepIndex int32) (actionsJobDetailView, actionsStepDetailView, bool) {
1029 for _, job := range run.Jobs {
1030 if job.JobIndex != jobIndex {
1031 continue
1032 }
1033 for _, step := range job.Steps {
1034 if step.StepIndex == stepIndex {
1035 return job, step, true
1036 }
1037 }
1038 return actionsJobDetailView{}, actionsStepDetailView{}, false
1039 }
1040 return actionsJobDetailView{}, actionsStepDetailView{}, false
1041 }
1042
1043 type actionsStepLogContent struct {
1044 Text string
1045 Source string
1046 Error string
1047 Truncated bool
1048 LastSeq int32
1049 DownloadURL string
1050 }
1051
1052 func (h *Handlers) loadStepLogContent(ctx context.Context, stepID int64) (actionsStepLogContent, error) {
1053 q := actionsdb.New()
1054 step, err := q.GetWorkflowStepByID(ctx, h.d.Pool, stepID)
1055 if err != nil {
1056 return actionsStepLogContent{}, err
1057 }
1058 if step.LogObjectKey.Valid && step.LogObjectKey.String != "" {
1059 return h.loadArchivedStepLog(ctx, step.LogObjectKey.String)
1060 }
1061 chunks, err := q.ListAllStepLogChunksForStep(ctx, h.d.Pool, step.ID)
1062 if err != nil {
1063 return actionsStepLogContent{}, err
1064 }
1065 buf := bytes.NewBuffer(make([]byte, 0, minInt(actionsStepLogRenderLimit, int(step.LogByteCount)+1)))
1066 truncated := false
1067 lastSeq := int32(-1)
1068 for _, chunk := range chunks {
1069 lastSeq = chunk.Seq
1070 if buf.Len() >= actionsStepLogRenderLimit {
1071 truncated = true
1072 break
1073 }
1074 remaining := actionsStepLogRenderLimit - buf.Len()
1075 if len(chunk.Chunk) > remaining {
1076 _, _ = buf.Write(chunk.Chunk[:remaining])
1077 truncated = true
1078 break
1079 }
1080 _, _ = buf.Write(chunk.Chunk)
1081 }
1082 return actionsStepLogContent{
1083 Text: strings.ToValidUTF8(buf.String(), "\uFFFD"),
1084 Source: "SQL chunks",
1085 Truncated: truncated,
1086 LastSeq: lastSeq,
1087 }, nil
1088 }
1089
1090 func (h *Handlers) loadArchivedStepLog(ctx context.Context, key string) (actionsStepLogContent, error) {
1091 if h.d.ObjectStore == nil {
1092 return actionsStepLogContent{
1093 Source: "object storage",
1094 Error: "Archived log storage is not configured for this server.",
1095 }, nil
1096 }
1097 rc, _, err := h.d.ObjectStore.Get(ctx, key)
1098 if err != nil {
1099 if errors.Is(err, storage.ErrNotFound) {
1100 return actionsStepLogContent{
1101 Source: "object storage",
1102 Error: "Archived log object was not found.",
1103 }, nil
1104 }
1105 return actionsStepLogContent{}, err
1106 }
1107 defer rc.Close()
1108 body, truncated, err := readLimitedLog(rc, actionsStepLogRenderLimit)
1109 if err != nil {
1110 return actionsStepLogContent{}, err
1111 }
1112 downloadURL, _ := h.d.ObjectStore.SignedURL(ctx, key, 15*time.Minute, http.MethodGet)
1113 return actionsStepLogContent{
1114 Text: strings.ToValidUTF8(string(body), "\uFFFD"),
1115 Source: "object storage",
1116 Truncated: truncated,
1117 DownloadURL: downloadURL,
1118 }, nil
1119 }
1120
1121 func readLimitedLog(r io.Reader, limit int) ([]byte, bool, error) {
1122 body, err := io.ReadAll(io.LimitReader(r, int64(limit)+1))
1123 if err != nil {
1124 return nil, false, err
1125 }
1126 if len(body) > limit {
1127 return body[:limit], true, nil
1128 }
1129 return body, false, nil
1130 }
1131
1132 func parsePositiveInt64Param(r *http.Request, name string) (int64, bool) {
1133 v, err := strconv.ParseInt(chi.URLParam(r, name), 10, 64)
1134 return v, err == nil && v > 0
1135 }
1136
1137 func parseNonNegativeInt32Param(r *http.Request, name string) (int32, bool) {
1138 v, err := strconv.ParseInt(chi.URLParam(r, name), 10, 32)
1139 return int32(v), err == nil && v >= 0
1140 }
1141
1142 func minInt(a, b int) int {
1143 if a < b {
1144 return a
1145 }
1146 return b
1147 }
1148
1149 func formatDuration(d time.Duration) string {
1150 if d <= 0 {
1151 return "—"
1152 }
1153 if d < time.Minute {
1154 return strconv.Itoa(int(d.Seconds())) + "s"
1155 }
1156 if d < time.Hour {
1157 mins := int(d / time.Minute)
1158 secs := int((d % time.Minute) / time.Second)
1159 if secs == 0 {
1160 return strconv.Itoa(mins) + "m"
1161 }
1162 return strconv.Itoa(mins) + "m " + strconv.Itoa(secs) + "s"
1163 }
1164 hours := int(d / time.Hour)
1165 mins := int((d % time.Hour) / time.Minute)
1166 return strconv.Itoa(hours) + "h " + strconv.Itoa(mins) + "m"
1167 }
1168
1169 func shortSHA(sha string) string {
1170 if len(sha) <= 7 {
1171 return sha
1172 }
1173 return sha[:7]
1174 }
1175