@@ -5,8 +5,10 @@ package repo |
| 5 | import ( | 5 | import ( |
| 6 | "bytes" | 6 | "bytes" |
| 7 | "context" | 7 | "context" |
| | 8 | + "encoding/json" |
| 8 | "net/http" | 9 | "net/http" |
| 9 | "net/http/httptest" | 10 | "net/http/httptest" |
| | 11 | + "net/url" |
| 10 | "strconv" | 12 | "strconv" |
| 11 | "strings" | 13 | "strings" |
| 12 | "testing" | 14 | "testing" |
@@ -16,7 +18,9 @@ import ( |
| 16 | "github.com/jackc/pgx/v5/pgtype" | 18 | "github.com/jackc/pgx/v5/pgtype" |
| 17 | | 19 | |
| 18 | actionsdb "github.com/tenseleyFlow/shithub/internal/actions/sqlc" | 20 | actionsdb "github.com/tenseleyFlow/shithub/internal/actions/sqlc" |
| | 21 | + "github.com/tenseleyFlow/shithub/internal/actions/workflow" |
| 19 | "github.com/tenseleyFlow/shithub/internal/infra/storage" | 22 | "github.com/tenseleyFlow/shithub/internal/infra/storage" |
| | 23 | + repogit "github.com/tenseleyFlow/shithub/internal/repos/git" |
| 20 | "github.com/tenseleyFlow/shithub/internal/web/middleware" | 24 | "github.com/tenseleyFlow/shithub/internal/web/middleware" |
| 21 | ) | 25 | ) |
| 22 | | 26 | |
@@ -132,6 +136,94 @@ func TestRepoTabActionsPaginatesTwentyRuns(t *testing.T) { |
| 132 | } | 136 | } |
| 133 | } | 137 | } |
| 134 | | 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 | + |
| 135 | func TestRepoActionRunRendersWorkflowRunJobsAndSteps(t *testing.T) { | 227 | func TestRepoActionRunRendersWorkflowRunJobsAndSteps(t *testing.T) { |
| 136 | t.Parallel() | 228 | t.Parallel() |
| 137 | f := newRepoFixture(t) | 229 | f := newRepoFixture(t) |
@@ -283,6 +375,7 @@ func TestRepoActionStepLogRendersSQLChunks(t *testing.T) { |
| 283 | body := resp.Body.String() | 375 | body := resp.Body.String() |
| 284 | for _, want := range []string{ | 376 | for _, want := range []string{ |
| 285 | "STEPLOG=Build:Run tests:SQL chunks::false;", | 377 | "STEPLOG=Build:Run tests:SQL chunks::false;", |
| | 378 | + "STREAM=/alice/public-repo/actions/runs/9/jobs/0/steps/0/log/stream?after=1;", |
| 286 | "LOG=hello\nworld\n;", | 379 | "LOG=hello\nworld\n;", |
| 287 | } { | 380 | } { |
| 288 | if !strings.Contains(body, want) { | 381 | if !strings.Contains(body, want) { |
@@ -291,6 +384,72 @@ func TestRepoActionStepLogRendersSQLChunks(t *testing.T) { |
| 291 | } | 384 | } |
| 292 | } | 385 | } |
| 293 | | 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 | + |
| 294 | func TestRepoActionStepLogRendersArchivedObject(t *testing.T) { | 453 | func TestRepoActionStepLogRendersArchivedObject(t *testing.T) { |
| 295 | t.Parallel() | 454 | t.Parallel() |
| 296 | f := newRepoFixture(t) | 455 | f := newRepoFixture(t) |
@@ -365,13 +524,80 @@ func (f *repoFixture) actionsMux(viewer middleware.CurrentUser) http.Handler { |
| 365 | next.ServeHTTP(w, r.WithContext(middleware.WithCurrentUserForTest(r.Context(), viewer))) | 524 | next.ServeHTTP(w, r.WithContext(middleware.WithCurrentUserForTest(r.Context(), viewer))) |
| 366 | }) | 525 | }) |
| 367 | }) | 526 | }) |
| | 527 | + mux.Get("/{owner}/{repo}/actions/runs/{runIndex}/jobs/{jobIndex}/steps/{stepIndex}/log/stream", f.handlers.repoActionStepLogStream) |
| 368 | mux.Get("/{owner}/{repo}/actions/runs/{runIndex}/jobs/{jobIndex}/steps/{stepIndex}", f.handlers.repoActionStepLog) | 528 | mux.Get("/{owner}/{repo}/actions/runs/{runIndex}/jobs/{jobIndex}/steps/{stepIndex}", f.handlers.repoActionStepLog) |
| 369 | mux.Get("/{owner}/{repo}/actions/runs/{runIndex}/status", f.handlers.repoActionRunStatus) | 529 | mux.Get("/{owner}/{repo}/actions/runs/{runIndex}/status", f.handlers.repoActionRunStatus) |
| 370 | mux.Get("/{owner}/{repo}/actions/runs/{runIndex}", f.handlers.repoActionRun) | 530 | mux.Get("/{owner}/{repo}/actions/runs/{runIndex}", f.handlers.repoActionRun) |
| | 531 | + mux.Post("/{owner}/{repo}/actions/workflows/{file}/dispatches", f.handlers.repoActionsDispatch) |
| 371 | mux.Get("/{owner}/{repo}/actions", f.handlers.repoTabActions) | 532 | mux.Get("/{owner}/{repo}/actions", f.handlers.repoTabActions) |
| 372 | return mux | 533 | return mux |
| 373 | } | 534 | } |
| 374 | | 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 | + |
| 375 | type workflowRunFixture struct { | 601 | type workflowRunFixture struct { |
| 376 | RunIndex int64 | 602 | RunIndex int64 |
| 377 | WorkflowFile string | 603 | WorkflowFile string |