tenseleyflow/shithub / 220e471

Browse files

repo/actions: cover workflow run list filters

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
220e471042ad1dc5f50a34cd0d7b23640caa7a8b
Parents
f9f7ba3
Tree
280dffc

2 changed files

StatusFile+-
A internal/web/handlers/repo/actions_test.go 205 0
M internal/web/handlers/repo/repo_test.go 1 0
internal/web/handlers/repo/actions_test.goadded
@@ -0,0 +1,205 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package repo
4
+
5
+import (
6
+	"context"
7
+	"net/http"
8
+	"net/http/httptest"
9
+	"strconv"
10
+	"strings"
11
+	"testing"
12
+	"time"
13
+
14
+	"github.com/go-chi/chi/v5"
15
+	"github.com/jackc/pgx/v5/pgtype"
16
+
17
+	actionsdb "github.com/tenseleyFlow/shithub/internal/actions/sqlc"
18
+	"github.com/tenseleyFlow/shithub/internal/web/middleware"
19
+)
20
+
21
+func TestRepoTabActionsFiltersWorkflowRunsAndSidebar(t *testing.T) {
22
+	t.Parallel()
23
+	f := newRepoFixture(t)
24
+	now := time.Date(2026, 5, 11, 12, 0, 0, 0, time.UTC)
25
+	f.insertWorkflowRun(t, workflowRunFixture{
26
+		RunIndex:      1,
27
+		WorkflowFile:  ".shithub/workflows/ci.yml",
28
+		WorkflowName:  "CI",
29
+		HeadRef:       "main",
30
+		Event:         actionsdb.WorkflowRunEventPush,
31
+		Status:        actionsdb.WorkflowRunStatusCompleted,
32
+		Conclusion:    actionsdb.CheckConclusionSuccess,
33
+		ActorUserID:   f.owner.ID,
34
+		CreatedOffset: -3 * time.Hour,
35
+		StartedOffset: -3 * time.Hour,
36
+		DoneOffset:    -2 * time.Hour,
37
+	}, now)
38
+	f.insertWorkflowRun(t, workflowRunFixture{
39
+		RunIndex:      2,
40
+		WorkflowFile:  ".shithub/workflows/deploy.yml",
41
+		WorkflowName:  "Deploy",
42
+		HeadRef:       "trunk",
43
+		Event:         actionsdb.WorkflowRunEventWorkflowDispatch,
44
+		Status:        actionsdb.WorkflowRunStatusRunning,
45
+		ActorUserID:   f.stranger.ID,
46
+		CreatedOffset: -90 * time.Minute,
47
+		StartedOffset: -80 * time.Minute,
48
+	}, now)
49
+	f.insertWorkflowRun(t, workflowRunFixture{
50
+		RunIndex:      3,
51
+		WorkflowFile:  ".shithub/workflows/ci.yml",
52
+		WorkflowName:  "CI",
53
+		HeadRef:       "feature",
54
+		Event:         actionsdb.WorkflowRunEventPullRequest,
55
+		Status:        actionsdb.WorkflowRunStatusQueued,
56
+		ActorUserID:   f.owner.ID,
57
+		CreatedOffset: -30 * time.Minute,
58
+	}, now)
59
+
60
+	resp := httptest.NewRecorder()
61
+	req := httptest.NewRequest(http.MethodGet, "/alice/public-repo/actions?workflow=.shithub/workflows/ci.yml&branch=main&event=push&status=completed&conclusion=success&actor=alice", nil)
62
+	f.actionsMux(viewerFor(f.owner)).ServeHTTP(resp, req)
63
+	if resp.Code != http.StatusOK {
64
+		t.Fatalf("status=%d body=%s", resp.Code, resp.Body.String())
65
+	}
66
+	body := resp.Body.String()
67
+	for _, want := range []string{
68
+		"COUNT=3;",
69
+		"FILTERED=1;",
70
+		"PAGE=1-1 of 1;",
71
+		"WF=CI:2:true;",
72
+		"WF=Deploy:1:false;",
73
+		"RUN=CI:#1:push:main:alice:success;",
74
+	} {
75
+		if !strings.Contains(body, want) {
76
+			t.Fatalf("body missing %q in %s", want, body)
77
+		}
78
+	}
79
+	if strings.Contains(body, "RUN=Deploy") || strings.Contains(body, "#3:") {
80
+		t.Fatalf("unfiltered run leaked into filtered response: %s", body)
81
+	}
82
+}
83
+
84
+func TestRepoTabActionsPaginatesTwentyRuns(t *testing.T) {
85
+	t.Parallel()
86
+	f := newRepoFixture(t)
87
+	now := time.Date(2026, 5, 11, 12, 0, 0, 0, time.UTC)
88
+	for i := 1; i <= 21; i++ {
89
+		f.insertWorkflowRun(t, workflowRunFixture{
90
+			RunIndex:      int64(i),
91
+			WorkflowFile:  ".shithub/workflows/ci.yml",
92
+			WorkflowName:  "CI",
93
+			HeadRef:       "main",
94
+			Event:         actionsdb.WorkflowRunEventPush,
95
+			Status:        actionsdb.WorkflowRunStatusQueued,
96
+			ActorUserID:   f.owner.ID,
97
+			CreatedOffset: time.Duration(i) * time.Minute,
98
+		}, now)
99
+	}
100
+
101
+	resp := httptest.NewRecorder()
102
+	req := httptest.NewRequest(http.MethodGet, "/alice/public-repo/actions", nil)
103
+	f.actionsMux(viewerFor(f.owner)).ServeHTTP(resp, req)
104
+	if resp.Code != http.StatusOK {
105
+		t.Fatalf("page 1 status=%d body=%s", resp.Code, resp.Body.String())
106
+	}
107
+	body := resp.Body.String()
108
+	if got := strings.Count(body, "RUN="); got != 20 {
109
+		t.Fatalf("page 1 run count=%d body=%s", got, body)
110
+	}
111
+	if !strings.Contains(body, "PAGE=1-20 of 21;") {
112
+		t.Fatalf("page 1 pagination missing: %s", body)
113
+	}
114
+	if strings.Contains(body, "#1:") {
115
+		t.Fatalf("oldest run appeared on page 1: %s", body)
116
+	}
117
+
118
+	resp = httptest.NewRecorder()
119
+	req = httptest.NewRequest(http.MethodGet, "/alice/public-repo/actions?page=2", nil)
120
+	f.actionsMux(viewerFor(f.owner)).ServeHTTP(resp, req)
121
+	if resp.Code != http.StatusOK {
122
+		t.Fatalf("page 2 status=%d body=%s", resp.Code, resp.Body.String())
123
+	}
124
+	body = resp.Body.String()
125
+	if got := strings.Count(body, "RUN="); got != 1 {
126
+		t.Fatalf("page 2 run count=%d body=%s", got, body)
127
+	}
128
+	if !strings.Contains(body, "PAGE=21-21 of 21;") || !strings.Contains(body, "#1:") {
129
+		t.Fatalf("page 2 pagination/run missing: %s", body)
130
+	}
131
+}
132
+
133
+func (f *repoFixture) actionsMux(viewer middleware.CurrentUser) http.Handler {
134
+	mux := chi.NewRouter()
135
+	mux.Use(func(next http.Handler) http.Handler {
136
+		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
137
+			next.ServeHTTP(w, r.WithContext(middleware.WithCurrentUserForTest(r.Context(), viewer)))
138
+		})
139
+	})
140
+	mux.Get("/{owner}/{repo}/actions", f.handlers.repoTabActions)
141
+	return mux
142
+}
143
+
144
+type workflowRunFixture struct {
145
+	RunIndex      int64
146
+	WorkflowFile  string
147
+	WorkflowName  string
148
+	HeadRef       string
149
+	Event         actionsdb.WorkflowRunEvent
150
+	Status        actionsdb.WorkflowRunStatus
151
+	Conclusion    actionsdb.CheckConclusion
152
+	ActorUserID   int64
153
+	CreatedOffset time.Duration
154
+	StartedOffset time.Duration
155
+	DoneOffset    time.Duration
156
+}
157
+
158
+func (f *repoFixture) insertWorkflowRun(t *testing.T, fx workflowRunFixture, base time.Time) {
159
+	t.Helper()
160
+	createdAt := base.Add(fx.CreatedOffset)
161
+	startedAt := pgtype.Timestamptz{}
162
+	completedAt := pgtype.Timestamptz{}
163
+	conclusion := actionsdb.NullCheckConclusion{}
164
+	if fx.StartedOffset != 0 || fx.Status == actionsdb.WorkflowRunStatusRunning || fx.Status == actionsdb.WorkflowRunStatusCompleted || fx.Status == actionsdb.WorkflowRunStatusCancelled {
165
+		startedAt = pgtype.Timestamptz{Time: base.Add(fx.StartedOffset), Valid: true}
166
+	}
167
+	if fx.DoneOffset != 0 || fx.Status == actionsdb.WorkflowRunStatusCompleted || fx.Status == actionsdb.WorkflowRunStatusCancelled {
168
+		completedAt = pgtype.Timestamptz{Time: base.Add(fx.DoneOffset), Valid: true}
169
+	}
170
+	if fx.Conclusion != "" {
171
+		conclusion = actionsdb.NullCheckConclusion{CheckConclusion: fx.Conclusion, Valid: true}
172
+	}
173
+	_, err := f.pool.Exec(context.Background(), `
174
+		INSERT INTO workflow_runs (
175
+			repo_id, run_index, workflow_file, workflow_name,
176
+			head_sha, head_ref, event, event_payload, actor_user_id,
177
+			status, conclusion, started_at, completed_at, created_at, updated_at
178
+		) VALUES (
179
+			$1, $2, $3, $4,
180
+			$5, $6, $7, '{}'::jsonb, $8,
181
+			$9, $10, $11, $12, $13, $14
182
+		)`,
183
+		f.publicRepo.ID,
184
+		fx.RunIndex,
185
+		fx.WorkflowFile,
186
+		fx.WorkflowName,
187
+		strings.Repeat(strconvDigit(fx.RunIndex), 40),
188
+		fx.HeadRef,
189
+		fx.Event,
190
+		fx.ActorUserID,
191
+		fx.Status,
192
+		conclusion,
193
+		startedAt,
194
+		completedAt,
195
+		createdAt,
196
+		createdAt,
197
+	)
198
+	if err != nil {
199
+		t.Fatalf("insert workflow run %d: %v", fx.RunIndex, err)
200
+	}
201
+}
202
+
203
+func strconvDigit(n int64) string {
204
+	return strconv.FormatInt(n%10, 10)
205
+}
internal/web/handlers/repo/repo_test.gomodified
@@ -144,6 +144,7 @@ func minimalTemplatesFS() fstest.MapFS {
144144
 		"errors/429.html":            {Data: body},
145145
 		"errors/500.html":            {Data: body},
146146
 		"repo/new.html":              {Data: []byte(`{{ define "page" }}OWNERS={{ range .Owners }}{{ .Token }}:{{ if eq .Token $.Form.Owner }}selected{{ end }}:{{ .Slug }};{{ end }}{{ end }}`)},
147
+		"repo/actions.html":          {Data: []byte(`{{ define "page" }}COUNT={{ .RunCount }};FILTERED={{ .FilteredRunCount }};PAGE={{ .Pagination.ResultText }};{{ range .Workflows }}WF={{ .Name }}:{{ .Count }}:{{ .Active }};{{ end }}{{ range .Runs }}RUN={{ .Title }}:#{{ .RunIndex }}:{{ .Event }}:{{ .HeadRef }}:{{ .ActorUsername }}:{{ .StateClass }};{{ end }}{{ end }}`)},
147148
 		"repo/settings_secrets.html": {Data: []byte(`{{ define "page" }}{{ with .Error }}ERROR={{ . }}{{ end }}{{ range .Secrets }}SECRET={{ .Name }};{{ end }}{{ range .Variables }}VAR={{ .Name }}:{{ .Value }};{{ end }}{{ end }}`)},
148149
 	}
149150
 }