| 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 |