Go · 26315 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 "encoding/json"
9 "net/http"
10 "net/http/httptest"
11 "net/url"
12 "strconv"
13 "strings"
14 "testing"
15 "time"
16
17 "github.com/go-chi/chi/v5"
18 "github.com/jackc/pgx/v5/pgtype"
19
20 actionsdb "github.com/tenseleyFlow/shithub/internal/actions/sqlc"
21 "github.com/tenseleyFlow/shithub/internal/actions/workflow"
22 "github.com/tenseleyFlow/shithub/internal/infra/storage"
23 repogit "github.com/tenseleyFlow/shithub/internal/repos/git"
24 "github.com/tenseleyFlow/shithub/internal/web/middleware"
25 )
26
27 func TestRepoTabActionsFiltersWorkflowRunsAndSidebar(t *testing.T) {
28 t.Parallel()
29 f := newRepoFixture(t)
30 now := time.Date(2026, 5, 11, 12, 0, 0, 0, time.UTC)
31 f.insertWorkflowRun(t, workflowRunFixture{
32 RunIndex: 1,
33 WorkflowFile: ".shithub/workflows/ci.yml",
34 WorkflowName: "CI",
35 HeadRef: "main",
36 Event: actionsdb.WorkflowRunEventPush,
37 Status: actionsdb.WorkflowRunStatusCompleted,
38 Conclusion: actionsdb.CheckConclusionSuccess,
39 ActorUserID: f.owner.ID,
40 CreatedOffset: -3 * time.Hour,
41 StartedOffset: -3 * time.Hour,
42 DoneOffset: -2 * time.Hour,
43 }, now)
44 f.insertWorkflowRun(t, workflowRunFixture{
45 RunIndex: 2,
46 WorkflowFile: ".shithub/workflows/deploy.yml",
47 WorkflowName: "Deploy",
48 HeadRef: "trunk",
49 Event: actionsdb.WorkflowRunEventWorkflowDispatch,
50 Status: actionsdb.WorkflowRunStatusRunning,
51 ActorUserID: f.stranger.ID,
52 CreatedOffset: -90 * time.Minute,
53 StartedOffset: -80 * time.Minute,
54 }, now)
55 f.insertWorkflowRun(t, workflowRunFixture{
56 RunIndex: 3,
57 WorkflowFile: ".shithub/workflows/ci.yml",
58 WorkflowName: "CI",
59 HeadRef: "feature",
60 Event: actionsdb.WorkflowRunEventPullRequest,
61 Status: actionsdb.WorkflowRunStatusQueued,
62 ActorUserID: f.owner.ID,
63 CreatedOffset: -30 * time.Minute,
64 }, now)
65
66 resp := httptest.NewRecorder()
67 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)
68 f.actionsMux(viewerFor(f.owner)).ServeHTTP(resp, req)
69 if resp.Code != http.StatusOK {
70 t.Fatalf("status=%d body=%s", resp.Code, resp.Body.String())
71 }
72 body := resp.Body.String()
73 for _, want := range []string{
74 "COUNT=3;",
75 "FILTERED=1;",
76 "PAGE=1-1 of 1;",
77 "WF=CI:2:true;",
78 "WF=Deploy:1:false;",
79 "RUN=CI:#1:push:main:alice:success;",
80 } {
81 if !strings.Contains(body, want) {
82 t.Fatalf("body missing %q in %s", want, body)
83 }
84 }
85 if strings.Contains(body, "RUN=Deploy") || strings.Contains(body, "#3:") {
86 t.Fatalf("unfiltered run leaked into filtered response: %s", body)
87 }
88 }
89
90 func TestRepoTabActionsPaginatesTwentyRuns(t *testing.T) {
91 t.Parallel()
92 f := newRepoFixture(t)
93 now := time.Date(2026, 5, 11, 12, 0, 0, 0, time.UTC)
94 for i := 1; i <= 21; i++ {
95 f.insertWorkflowRun(t, workflowRunFixture{
96 RunIndex: int64(i),
97 WorkflowFile: ".shithub/workflows/ci.yml",
98 WorkflowName: "CI",
99 HeadRef: "main",
100 Event: actionsdb.WorkflowRunEventPush,
101 Status: actionsdb.WorkflowRunStatusQueued,
102 ActorUserID: f.owner.ID,
103 CreatedOffset: time.Duration(i) * time.Minute,
104 }, now)
105 }
106
107 resp := httptest.NewRecorder()
108 req := httptest.NewRequest(http.MethodGet, "/alice/public-repo/actions", nil)
109 f.actionsMux(viewerFor(f.owner)).ServeHTTP(resp, req)
110 if resp.Code != http.StatusOK {
111 t.Fatalf("page 1 status=%d body=%s", resp.Code, resp.Body.String())
112 }
113 body := resp.Body.String()
114 if got := strings.Count(body, "RUN="); got != 20 {
115 t.Fatalf("page 1 run count=%d body=%s", got, body)
116 }
117 if !strings.Contains(body, "PAGE=1-20 of 21;") {
118 t.Fatalf("page 1 pagination missing: %s", body)
119 }
120 if strings.Contains(body, "#1:") {
121 t.Fatalf("oldest run appeared on page 1: %s", body)
122 }
123
124 resp = httptest.NewRecorder()
125 req = httptest.NewRequest(http.MethodGet, "/alice/public-repo/actions?page=2", nil)
126 f.actionsMux(viewerFor(f.owner)).ServeHTTP(resp, req)
127 if resp.Code != http.StatusOK {
128 t.Fatalf("page 2 status=%d body=%s", resp.Code, resp.Body.String())
129 }
130 body = resp.Body.String()
131 if got := strings.Count(body, "RUN="); got != 1 {
132 t.Fatalf("page 2 run count=%d body=%s", got, body)
133 }
134 if !strings.Contains(body, "PAGE=21-21 of 21;") || !strings.Contains(body, "#1:") {
135 t.Fatalf("page 2 pagination/run missing: %s", body)
136 }
137 }
138
139 func TestRepoTabActionsRendersDispatchWorkflowsForWriters(t *testing.T) {
140 t.Parallel()
141 f := newRepoFixture(t)
142 f.seedWorkflowFile(t, "manual.yml", dispatchWorkflowFixture)
143
144 resp := httptest.NewRecorder()
145 req := httptest.NewRequest(http.MethodGet, "/alice/public-repo/actions", nil)
146 f.actionsMux(viewerFor(f.owner)).ServeHTTP(resp, req)
147 if resp.Code != http.StatusOK {
148 t.Fatalf("owner status=%d body=%s", resp.Code, resp.Body.String())
149 }
150 body := resp.Body.String()
151 for _, want := range []string{
152 "DISPATCH=Manual:/alice/public-repo/actions/workflows/manual.yml/dispatches:",
153 "env/choice/true//staging|prod|,",
154 "dry_run/boolean/false/true/,",
155 } {
156 if !strings.Contains(body, want) {
157 t.Fatalf("owner body missing %q in %s", want, body)
158 }
159 }
160
161 resp = httptest.NewRecorder()
162 req = httptest.NewRequest(http.MethodGet, "/alice/public-repo/actions", nil)
163 f.actionsMux(viewerFor(f.stranger)).ServeHTTP(resp, req)
164 if resp.Code != http.StatusOK {
165 t.Fatalf("stranger status=%d body=%s", resp.Code, resp.Body.String())
166 }
167 if strings.Contains(resp.Body.String(), "DISPATCH=") {
168 t.Fatalf("dispatch controls leaked to non-writer: %s", resp.Body.String())
169 }
170 }
171
172 func TestRepoActionsDispatchAcceptsFormInputs(t *testing.T) {
173 t.Parallel()
174 f := newRepoFixture(t)
175 f.seedWorkflowFile(t, "manual.yml", dispatchWorkflowFixture)
176
177 form := url.Values{}
178 form.Set("ref", "trunk")
179 form.Set("inputs.env", "prod")
180 req := httptest.NewRequest(http.MethodPost, "/alice/public-repo/actions/workflows/manual.yml/dispatches", strings.NewReader(form.Encode()))
181 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
182 resp := httptest.NewRecorder()
183 f.actionsMux(viewerFor(f.owner)).ServeHTTP(resp, req)
184 if resp.Code != http.StatusSeeOther {
185 t.Fatalf("status=%d body=%s", resp.Code, resp.Body.String())
186 }
187 if loc := resp.Header().Get("Location"); loc != "/alice/public-repo/actions?workflow=.shithub%2Fworkflows%2Fmanual.yml&event=workflow_dispatch" {
188 t.Fatalf("Location=%q", loc)
189 }
190
191 var raw []byte
192 err := f.pool.QueryRow(context.Background(), `
193 SELECT event_payload
194 FROM workflow_runs
195 WHERE repo_id = $1 AND workflow_file = '.shithub/workflows/manual.yml'`,
196 f.publicRepo.ID,
197 ).Scan(&raw)
198 if err != nil {
199 t.Fatalf("select workflow dispatch run: %v", err)
200 }
201 var payload map[string]map[string]string
202 if err := json.Unmarshal(raw, &payload); err != nil {
203 t.Fatalf("payload json: %v", err)
204 }
205 if got := payload["inputs"]["env"]; got != "prod" {
206 t.Fatalf("env input=%q", got)
207 }
208 if got := payload["inputs"]["dry_run"]; got != "true" {
209 t.Fatalf("dry_run default=%q", got)
210 }
211 }
212
213 func TestNormalizeDispatchInputsRejectsUnknownAndInvalidChoice(t *testing.T) {
214 t.Parallel()
215 specs := dispatchWorkflowInputSpecs()
216 if _, err := normalizeDispatchInputs(map[string]string{"bogus": "x"}, specs); err == nil {
217 t.Fatal("unknown input accepted")
218 }
219 if _, err := normalizeDispatchInputs(map[string]string{"env": "qa"}, specs); err == nil {
220 t.Fatal("invalid choice accepted")
221 }
222 if _, err := normalizeDispatchInputs(nil, specs); err == nil {
223 t.Fatal("missing required input accepted")
224 }
225 }
226
227 func TestRepoActionRunRendersWorkflowRunJobsAndSteps(t *testing.T) {
228 t.Parallel()
229 f := newRepoFixture(t)
230 now := time.Date(2026, 5, 11, 12, 0, 0, 0, time.UTC)
231 runID := f.insertWorkflowRun(t, workflowRunFixture{
232 RunIndex: 7,
233 WorkflowFile: ".shithub/workflows/ci.yml",
234 WorkflowName: "CI",
235 HeadRef: "trunk",
236 Event: actionsdb.WorkflowRunEventPush,
237 Status: actionsdb.WorkflowRunStatusCompleted,
238 Conclusion: actionsdb.CheckConclusionFailure,
239 ActorUserID: f.owner.ID,
240 CreatedOffset: -20 * time.Minute,
241 StartedOffset: -19 * time.Minute,
242 DoneOffset: -10 * time.Minute,
243 }, now)
244 buildID := f.insertWorkflowJob(t, workflowJobFixture{
245 RunID: runID,
246 JobIndex: 0,
247 JobKey: "build",
248 JobName: "Build",
249 RunsOn: "ubuntu-latest",
250 Status: actionsdb.WorkflowJobStatusCompleted,
251 Conclusion: actionsdb.CheckConclusionSuccess,
252 StartedAt: now.Add(-19 * time.Minute),
253 CompletedAt: now.Add(-15 * time.Minute),
254 })
255 testID := f.insertWorkflowJob(t, workflowJobFixture{
256 RunID: runID,
257 JobIndex: 1,
258 JobKey: "test",
259 JobName: "Test",
260 RunsOn: "ubuntu-latest",
261 Needs: []string{"build"},
262 Status: actionsdb.WorkflowJobStatusCompleted,
263 Conclusion: actionsdb.CheckConclusionFailure,
264 StartedAt: now.Add(-14 * time.Minute),
265 CompletedAt: now.Add(-10 * time.Minute),
266 })
267 f.insertWorkflowStep(t, workflowStepFixture{
268 JobID: buildID,
269 StepIndex: 0,
270 StepName: "Checkout",
271 UsesAlias: "actions/checkout@v4",
272 Status: actionsdb.WorkflowStepStatusCompleted,
273 Conclusion: actionsdb.CheckConclusionSuccess,
274 CompletedAt: now.Add(-18 * time.Minute),
275 })
276 f.insertWorkflowStep(t, workflowStepFixture{
277 JobID: testID,
278 StepIndex: 0,
279 RunCommand: "go test ./...",
280 Status: actionsdb.WorkflowStepStatusCompleted,
281 Conclusion: actionsdb.CheckConclusionFailure,
282 CompletedAt: now.Add(-10 * time.Minute),
283 })
284
285 resp := httptest.NewRecorder()
286 req := httptest.NewRequest(http.MethodGet, "/alice/public-repo/actions/runs/7", nil)
287 f.actionsMux(viewerFor(f.owner)).ServeHTTP(resp, req)
288 if resp.Code != http.StatusOK {
289 t.Fatalf("status=%d body=%s", resp.Code, resp.Body.String())
290 }
291 body := resp.Body.String()
292 for _, want := range []string{
293 "RUN=CI:#7:push:alice:failure;",
294 "SUMMARY=2:2:1:0;",
295 "JOB=Build:success::ubuntu-latest;",
296 "STEP=Checkout:success:/alice/public-repo/actions/runs/7/jobs/0/steps/0;",
297 "JOB=Test:failure:build:ubuntu-latest;",
298 "STEP=go test ./...:failure:/alice/public-repo/actions/runs/7/jobs/1/steps/0;",
299 } {
300 if !strings.Contains(body, want) {
301 t.Fatalf("body missing %q in %s", want, body)
302 }
303 }
304 }
305
306 func TestRepoActionRunStatusRendersPollingFragment(t *testing.T) {
307 t.Parallel()
308 f := newRepoFixture(t)
309 now := time.Date(2026, 5, 11, 12, 0, 0, 0, time.UTC)
310 f.insertWorkflowRun(t, workflowRunFixture{
311 RunIndex: 8,
312 WorkflowFile: ".shithub/workflows/deploy.yml",
313 WorkflowName: "Deploy",
314 HeadRef: "trunk",
315 Event: actionsdb.WorkflowRunEventWorkflowDispatch,
316 Status: actionsdb.WorkflowRunStatusRunning,
317 ActorUserID: f.owner.ID,
318 CreatedOffset: -5 * time.Minute,
319 StartedOffset: -4 * time.Minute,
320 }, now)
321
322 resp := httptest.NewRecorder()
323 req := httptest.NewRequest(http.MethodGet, "/alice/public-repo/actions/runs/8/status", nil)
324 f.actionsMux(viewerFor(f.owner)).ServeHTTP(resp, req)
325 if resp.Code != http.StatusOK {
326 t.Fatalf("status=%d body=%s", resp.Code, resp.Body.String())
327 }
328 want := "STATUS=running:false:/alice/public-repo/actions/runs/8/status;"
329 if body := resp.Body.String(); !strings.Contains(body, want) {
330 t.Fatalf("status fragment missing %q in %s", want, body)
331 }
332 }
333
334 func TestRepoActionStepLogRendersSQLChunks(t *testing.T) {
335 t.Parallel()
336 f := newRepoFixture(t)
337 now := time.Date(2026, 5, 11, 12, 0, 0, 0, time.UTC)
338 runID := f.insertWorkflowRun(t, workflowRunFixture{
339 RunIndex: 9,
340 WorkflowFile: ".shithub/workflows/ci.yml",
341 WorkflowName: "CI",
342 HeadRef: "trunk",
343 Event: actionsdb.WorkflowRunEventPush,
344 Status: actionsdb.WorkflowRunStatusRunning,
345 ActorUserID: f.owner.ID,
346 CreatedOffset: -5 * time.Minute,
347 StartedOffset: -4 * time.Minute,
348 }, now)
349 jobID := f.insertWorkflowJob(t, workflowJobFixture{
350 RunID: runID,
351 JobIndex: 0,
352 JobKey: "build",
353 JobName: "Build",
354 RunsOn: "ubuntu-latest",
355 Status: actionsdb.WorkflowJobStatusRunning,
356 StartedAt: now.Add(-4 * time.Minute),
357 })
358 stepID := f.insertWorkflowStep(t, workflowStepFixture{
359 JobID: jobID,
360 StepIndex: 0,
361 StepName: "Run tests",
362 RunCommand: "go test ./...",
363 Status: actionsdb.WorkflowStepStatusRunning,
364 StartedAt: now.Add(-3 * time.Minute),
365 })
366 f.insertStepLogChunk(t, stepID, 0, "hello\n")
367 f.insertStepLogChunk(t, stepID, 1, "world\n")
368
369 resp := httptest.NewRecorder()
370 req := httptest.NewRequest(http.MethodGet, "/alice/public-repo/actions/runs/9/jobs/0/steps/0", nil)
371 f.actionsMux(viewerFor(f.owner)).ServeHTTP(resp, req)
372 if resp.Code != http.StatusOK {
373 t.Fatalf("status=%d body=%s", resp.Code, resp.Body.String())
374 }
375 body := resp.Body.String()
376 for _, want := range []string{
377 "STEPLOG=Build:Run tests:SQL chunks::false;",
378 "STREAM=/alice/public-repo/actions/runs/9/jobs/0/steps/0/log/stream?after=1;",
379 "LOG=hello\nworld\n;",
380 } {
381 if !strings.Contains(body, want) {
382 t.Fatalf("body missing %q in %s", want, body)
383 }
384 }
385 }
386
387 func TestRepoActionStepLogStreamResumesAndClosesForTerminalStep(t *testing.T) {
388 t.Parallel()
389 f := newRepoFixture(t)
390 now := time.Date(2026, 5, 11, 12, 0, 0, 0, time.UTC)
391 runID := f.insertWorkflowRun(t, workflowRunFixture{
392 RunIndex: 11,
393 WorkflowFile: ".shithub/workflows/ci.yml",
394 WorkflowName: "CI",
395 HeadRef: "trunk",
396 Event: actionsdb.WorkflowRunEventPush,
397 Status: actionsdb.WorkflowRunStatusCompleted,
398 Conclusion: actionsdb.CheckConclusionSuccess,
399 ActorUserID: f.owner.ID,
400 CreatedOffset: -5 * time.Minute,
401 StartedOffset: -4 * time.Minute,
402 DoneOffset: -1 * time.Minute,
403 }, now)
404 jobID := f.insertWorkflowJob(t, workflowJobFixture{
405 RunID: runID,
406 JobIndex: 0,
407 JobKey: "build",
408 JobName: "Build",
409 RunsOn: "ubuntu-latest",
410 Status: actionsdb.WorkflowJobStatusCompleted,
411 Conclusion: actionsdb.CheckConclusionSuccess,
412 StartedAt: now.Add(-4 * time.Minute),
413 CompletedAt: now.Add(-1 * time.Minute),
414 })
415 stepID := f.insertWorkflowStep(t, workflowStepFixture{
416 JobID: jobID,
417 StepIndex: 0,
418 StepName: "Run",
419 RunCommand: "printf done",
420 Status: actionsdb.WorkflowStepStatusCompleted,
421 Conclusion: actionsdb.CheckConclusionSuccess,
422 StartedAt: now.Add(-3 * time.Minute),
423 CompletedAt: now.Add(-1 * time.Minute),
424 })
425 f.insertStepLogChunk(t, stepID, 0, "hello\n")
426 f.insertStepLogChunk(t, stepID, 1, "world\n")
427
428 resp := httptest.NewRecorder()
429 req := httptest.NewRequest(http.MethodGet, "/alice/public-repo/actions/runs/11/jobs/0/steps/0/log/stream?after=0", nil)
430 f.actionsMux(viewerFor(f.owner)).ServeHTTP(resp, req)
431 if resp.Code != http.StatusOK {
432 t.Fatalf("status=%d body=%s", resp.Code, resp.Body.String())
433 }
434 if ct := resp.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/event-stream") {
435 t.Fatalf("content-type=%q", ct)
436 }
437 body := resp.Body.String()
438 for _, want := range []string{
439 "id: 1\n",
440 "event: chunk\n",
441 `"chunk_b64":"d29ybGQK"`,
442 "event: done\n",
443 } {
444 if !strings.Contains(body, want) {
445 t.Fatalf("stream body missing %q in %s", want, body)
446 }
447 }
448 if strings.Contains(body, "aGVsbG8K") {
449 t.Fatalf("stream replayed chunk before Last-Event-ID: %s", body)
450 }
451 }
452
453 func TestRepoActionStepLogRendersArchivedObject(t *testing.T) {
454 t.Parallel()
455 f := newRepoFixture(t)
456 now := time.Date(2026, 5, 11, 12, 0, 0, 0, time.UTC)
457 runID := f.insertWorkflowRun(t, workflowRunFixture{
458 RunIndex: 10,
459 WorkflowFile: ".shithub/workflows/ci.yml",
460 WorkflowName: "CI",
461 HeadRef: "trunk",
462 Event: actionsdb.WorkflowRunEventPush,
463 Status: actionsdb.WorkflowRunStatusCompleted,
464 Conclusion: actionsdb.CheckConclusionSuccess,
465 ActorUserID: f.owner.ID,
466 CreatedOffset: -5 * time.Minute,
467 StartedOffset: -4 * time.Minute,
468 DoneOffset: -1 * time.Minute,
469 }, now)
470 jobID := f.insertWorkflowJob(t, workflowJobFixture{
471 RunID: runID,
472 JobIndex: 0,
473 JobKey: "build",
474 JobName: "Build",
475 RunsOn: "ubuntu-latest",
476 Status: actionsdb.WorkflowJobStatusCompleted,
477 Conclusion: actionsdb.CheckConclusionSuccess,
478 StartedAt: now.Add(-4 * time.Minute),
479 CompletedAt: now.Add(-1 * time.Minute),
480 })
481 stepID := f.insertWorkflowStep(t, workflowStepFixture{
482 JobID: jobID,
483 StepIndex: 0,
484 StepName: "Archive",
485 RunCommand: "printf archived",
486 Status: actionsdb.WorkflowStepStatusCompleted,
487 Conclusion: actionsdb.CheckConclusionSuccess,
488 StartedAt: now.Add(-3 * time.Minute),
489 CompletedAt: now.Add(-1 * time.Minute),
490 })
491 key := "actions/runs/" + strconv.FormatInt(runID, 10) + "/jobs/" + strconv.FormatInt(jobID, 10) + "/steps/" + strconv.FormatInt(stepID, 10) + ".log"
492 if _, err := f.objectStore.Put(context.Background(), key, bytes.NewReader([]byte("archived\n")), storage.PutOpts{ContentType: "text/plain; charset=utf-8"}); err != nil {
493 t.Fatalf("put log object: %v", err)
494 }
495 if _, err := actionsdb.New().UpdateWorkflowStepLogObject(context.Background(), f.pool, actionsdb.UpdateWorkflowStepLogObjectParams{
496 LogObjectKey: pgtype.Text{String: key, Valid: true},
497 LogByteCount: int64(len("archived\n")),
498 ID: stepID,
499 }); err != nil {
500 t.Fatalf("update log object: %v", err)
501 }
502
503 resp := httptest.NewRecorder()
504 req := httptest.NewRequest(http.MethodGet, "/alice/public-repo/actions/runs/10/jobs/0/steps/0", nil)
505 f.actionsMux(viewerFor(f.owner)).ServeHTTP(resp, req)
506 if resp.Code != http.StatusOK {
507 t.Fatalf("status=%d body=%s", resp.Code, resp.Body.String())
508 }
509 body := resp.Body.String()
510 for _, want := range []string{
511 "STEPLOG=Build:Archive:object storage:mem://actions/runs/",
512 "LOG=archived\n;",
513 } {
514 if !strings.Contains(body, want) {
515 t.Fatalf("body missing %q in %s", want, body)
516 }
517 }
518 }
519
520 func (f *repoFixture) actionsMux(viewer middleware.CurrentUser) http.Handler {
521 mux := chi.NewRouter()
522 mux.Use(func(next http.Handler) http.Handler {
523 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
524 next.ServeHTTP(w, r.WithContext(middleware.WithCurrentUserForTest(r.Context(), viewer)))
525 })
526 })
527 mux.Get("/{owner}/{repo}/actions/runs/{runIndex}/jobs/{jobIndex}/steps/{stepIndex}/log/stream", f.handlers.repoActionStepLogStream)
528 mux.Get("/{owner}/{repo}/actions/runs/{runIndex}/jobs/{jobIndex}/steps/{stepIndex}", f.handlers.repoActionStepLog)
529 mux.Get("/{owner}/{repo}/actions/runs/{runIndex}/status", f.handlers.repoActionRunStatus)
530 mux.Get("/{owner}/{repo}/actions/runs/{runIndex}", f.handlers.repoActionRun)
531 mux.Post("/{owner}/{repo}/actions/workflows/{file}/dispatches", f.handlers.repoActionsDispatch)
532 mux.Get("/{owner}/{repo}/actions", f.handlers.repoTabActions)
533 return mux
534 }
535
536 const dispatchWorkflowFixture = `name: Manual
537 on:
538 workflow_dispatch:
539 inputs:
540 env:
541 description: Environment
542 required: true
543 type: choice
544 options:
545 - staging
546 - prod
547 dry_run:
548 description: Dry run
549 type: boolean
550 default: "true"
551 jobs:
552 build:
553 runs-on: ubuntu-latest
554 steps:
555 - run: echo hello
556 `
557
558 func dispatchWorkflowInputSpecs() []workflow.DispatchInput {
559 return []workflow.DispatchInput{
560 {
561 Name: "env",
562 Type: "choice",
563 Required: true,
564 Options: []string{"staging", "prod"},
565 },
566 {
567 Name: "dry_run",
568 Type: "boolean",
569 Default: "true",
570 },
571 }
572 }
573
574 func (f *repoFixture) seedWorkflowFile(t *testing.T, name, body string) string {
575 t.Helper()
576 ctx := context.Background()
577 gitDir, err := f.handlers.d.RepoFS.RepoPath(f.owner.Username, f.publicRepo.Name)
578 if err != nil {
579 t.Fatalf("RepoPath: %v", err)
580 }
581 if err := f.handlers.d.RepoFS.InitBare(ctx, gitDir); err != nil {
582 t.Fatalf("InitBare: %v", err)
583 }
584 commit, err := (repogit.InitialCommit{
585 GitDir: gitDir,
586 AuthorName: "Alice",
587 AuthorEmail: "alice@example.test",
588 Branch: "trunk",
589 Message: "Add workflow",
590 When: time.Date(2026, 5, 11, 12, 0, 0, 0, time.UTC),
591 Files: []repogit.FileEntry{
592 {Path: ".shithub/workflows/" + name, Body: []byte(body)},
593 },
594 }).Build(ctx)
595 if err != nil {
596 t.Fatalf("InitialCommit.Build: %v", err)
597 }
598 return commit
599 }
600
601 type workflowRunFixture struct {
602 RunIndex int64
603 WorkflowFile string
604 WorkflowName string
605 HeadRef string
606 Event actionsdb.WorkflowRunEvent
607 Status actionsdb.WorkflowRunStatus
608 Conclusion actionsdb.CheckConclusion
609 ActorUserID int64
610 CreatedOffset time.Duration
611 StartedOffset time.Duration
612 DoneOffset time.Duration
613 RepoID int64
614 }
615
616 func (f *repoFixture) insertWorkflowRun(t *testing.T, fx workflowRunFixture, base time.Time) int64 {
617 t.Helper()
618 repoID := fx.RepoID
619 if repoID == 0 {
620 repoID = f.publicRepo.ID
621 }
622 createdAt := base.Add(fx.CreatedOffset)
623 startedAt := pgtype.Timestamptz{}
624 completedAt := pgtype.Timestamptz{}
625 conclusion := actionsdb.NullCheckConclusion{}
626 if fx.StartedOffset != 0 || fx.Status == actionsdb.WorkflowRunStatusRunning || fx.Status == actionsdb.WorkflowRunStatusCompleted || fx.Status == actionsdb.WorkflowRunStatusCancelled {
627 startedAt = pgtype.Timestamptz{Time: base.Add(fx.StartedOffset), Valid: true}
628 }
629 if fx.DoneOffset != 0 || fx.Status == actionsdb.WorkflowRunStatusCompleted || fx.Status == actionsdb.WorkflowRunStatusCancelled {
630 completedAt = pgtype.Timestamptz{Time: base.Add(fx.DoneOffset), Valid: true}
631 }
632 if fx.Conclusion != "" {
633 conclusion = actionsdb.NullCheckConclusion{CheckConclusion: fx.Conclusion, Valid: true}
634 }
635 var id int64
636 err := f.pool.QueryRow(
637 context.Background(), `
638 INSERT INTO workflow_runs (
639 repo_id, run_index, workflow_file, workflow_name,
640 head_sha, head_ref, event, event_payload, actor_user_id,
641 status, conclusion, started_at, completed_at, created_at, updated_at
642 ) VALUES (
643 $1, $2, $3, $4,
644 $5, $6, $7, '{}'::jsonb, $8,
645 $9, $10, $11, $12, $13, $14
646 )
647 RETURNING id`,
648 repoID,
649 fx.RunIndex,
650 fx.WorkflowFile,
651 fx.WorkflowName,
652 strings.Repeat(strconvDigit(fx.RunIndex), 40),
653 fx.HeadRef,
654 fx.Event,
655 fx.ActorUserID,
656 fx.Status,
657 conclusion,
658 startedAt,
659 completedAt,
660 createdAt,
661 createdAt,
662 ).Scan(&id)
663 if err != nil {
664 t.Fatalf("insert workflow run %d: %v", fx.RunIndex, err)
665 }
666 return id
667 }
668
669 func strconvDigit(n int64) string {
670 return strconv.FormatInt(n%10, 10)
671 }
672
673 type workflowJobFixture struct {
674 RunID int64
675 JobIndex int32
676 JobKey string
677 JobName string
678 RunsOn string
679 Needs []string
680 Status actionsdb.WorkflowJobStatus
681 Conclusion actionsdb.CheckConclusion
682 StartedAt time.Time
683 CompletedAt time.Time
684 }
685
686 func (f *repoFixture) insertWorkflowJob(t *testing.T, fx workflowJobFixture) int64 {
687 t.Helper()
688 status := fx.Status
689 if status == "" {
690 status = actionsdb.WorkflowJobStatusQueued
691 }
692 conclusion := actionsdb.NullCheckConclusion{}
693 if fx.Conclusion != "" {
694 conclusion = actionsdb.NullCheckConclusion{CheckConclusion: fx.Conclusion, Valid: true}
695 }
696 startedAt := pgtype.Timestamptz{}
697 if !fx.StartedAt.IsZero() {
698 startedAt = pgtype.Timestamptz{Time: fx.StartedAt, Valid: true}
699 }
700 completedAt := pgtype.Timestamptz{}
701 if !fx.CompletedAt.IsZero() {
702 completedAt = pgtype.Timestamptz{Time: fx.CompletedAt, Valid: true}
703 }
704 needs := fx.Needs
705 if needs == nil {
706 needs = []string{}
707 }
708 runnerID := pgtype.Int8{}
709 if status == actionsdb.WorkflowJobStatusRunning || status == actionsdb.WorkflowJobStatusCompleted {
710 runnerID = pgtype.Int8{Int64: f.insertWorkflowRunner(t), Valid: true}
711 }
712 var id int64
713 err := f.pool.QueryRow(
714 context.Background(), `
715 INSERT INTO workflow_jobs (
716 run_id, job_index, job_key, job_name, runs_on, needs_jobs,
717 runner_id, status, conclusion, started_at, completed_at
718 ) VALUES (
719 $1, $2, $3, $4, $5, $6,
720 $7, $8, $9, $10, $11
721 )
722 RETURNING id`,
723 fx.RunID,
724 fx.JobIndex,
725 fx.JobKey,
726 fx.JobName,
727 fx.RunsOn,
728 needs,
729 runnerID,
730 status,
731 conclusion,
732 startedAt,
733 completedAt,
734 ).Scan(&id)
735 if err != nil {
736 t.Fatalf("insert workflow job %s: %v", fx.JobKey, err)
737 }
738 return id
739 }
740
741 func (f *repoFixture) insertWorkflowRunner(t *testing.T) int64 {
742 t.Helper()
743 var id int64
744 err := f.pool.QueryRow(
745 context.Background(), `
746 INSERT INTO workflow_runners (name, labels, status)
747 VALUES ($1, ARRAY['ubuntu-latest']::text[], 'busy')
748 RETURNING id`,
749 "runner-"+strconv.FormatInt(time.Now().UnixNano(), 10),
750 ).Scan(&id)
751 if err != nil {
752 t.Fatalf("insert workflow runner: %v", err)
753 }
754 return id
755 }
756
757 type workflowStepFixture struct {
758 JobID int64
759 StepIndex int32
760 StepName string
761 RunCommand string
762 UsesAlias string
763 Status actionsdb.WorkflowStepStatus
764 Conclusion actionsdb.CheckConclusion
765 StartedAt time.Time
766 CompletedAt time.Time
767 }
768
769 func (f *repoFixture) insertWorkflowStep(t *testing.T, fx workflowStepFixture) int64 {
770 t.Helper()
771 status := fx.Status
772 if status == "" {
773 status = actionsdb.WorkflowStepStatusQueued
774 }
775 runCommand := fx.RunCommand
776 if runCommand == "" && fx.UsesAlias == "" {
777 runCommand = "true"
778 }
779 conclusion := actionsdb.NullCheckConclusion{}
780 if fx.Conclusion != "" {
781 conclusion = actionsdb.NullCheckConclusion{CheckConclusion: fx.Conclusion, Valid: true}
782 }
783 startedAt := pgtype.Timestamptz{}
784 if !fx.StartedAt.IsZero() {
785 startedAt = pgtype.Timestamptz{Time: fx.StartedAt, Valid: true}
786 }
787 completedAt := pgtype.Timestamptz{}
788 if !fx.CompletedAt.IsZero() {
789 completedAt = pgtype.Timestamptz{Time: fx.CompletedAt, Valid: true}
790 }
791 var id int64
792 err := f.pool.QueryRow(
793 context.Background(), `
794 INSERT INTO workflow_steps (
795 job_id, step_index, step_name, run_command, uses_alias,
796 status, conclusion, started_at, completed_at
797 ) VALUES (
798 $1, $2, $3, $4, $5,
799 $6, $7, $8, $9
800 )
801 RETURNING id`,
802 fx.JobID,
803 fx.StepIndex,
804 fx.StepName,
805 runCommand,
806 fx.UsesAlias,
807 status,
808 conclusion,
809 startedAt,
810 completedAt,
811 ).Scan(&id)
812 if err != nil {
813 t.Fatalf("insert workflow step %d: %v", fx.StepIndex, err)
814 }
815 return id
816 }
817
818 func (f *repoFixture) insertStepLogChunk(t *testing.T, stepID int64, seq int32, chunk string) {
819 t.Helper()
820 if _, err := actionsdb.New().AppendStepLogChunk(context.Background(), f.pool, actionsdb.AppendStepLogChunkParams{
821 StepID: stepID,
822 Seq: seq,
823 Chunk: []byte(chunk),
824 }); err != nil {
825 t.Fatalf("insert step log chunk %d: %v", seq, err)
826 }
827 }
828