From d6dabca386849f4bbd6ff78845f7f09238f439d5 Mon Sep 17 00:00:00 2001 From: Max Mill Date: Fri, 28 Aug 2026 15:29:36 +0200 Subject: [PATCH 01/41] feat: establish provider-neutral workflow capabilities Workflow discovery, run reads, and dispatch are independent optional provider contracts so GitHub support does not leak into server or frontend boundaries. --- context/provider-architecture.md | 3 + internal/platform/client.go | 14 +++ internal/platform/registry.go | 39 +++++++ internal/platform/registry_test.go | 110 ++++++++++++++++++ internal/platform/types.go | 96 +++++++++++++++ .../server/httpapi/repository_resolver.go | 9 ++ .../httpapi/repository_resolver_test.go | 18 +++ internal/server/httpapi/repository_types.go | 4 + internal/server/operation_availability.go | 4 + .../server/operation_availability_test.go | 53 +++++++++ 10 files changed, 350 insertions(+) diff --git a/context/provider-architecture.md b/context/provider-architecture.md index 7876f5b2c0..5da544d54b 100644 --- a/context/provider-architecture.md +++ b/context/provider-architecture.md @@ -65,6 +65,9 @@ Rules: - Capability flags and implemented interfaces must agree. - Handlers must check capabilities before performing mutations. A missing capability is a feature-level failure, not a whole-provider failure. +- Keep workflow approval and workflow dispatch separate: approval advances an + existing run, while dispatch starts a new one; neither contract may assume + GitHub Actions semantics (`internal/platform/client.go::WorkflowDispatcher`). - Provider-backed comment deletes remove synchronized local rows only after upstream synchronization observes provider absence. DELETE itself changes no SQLite comment state; the UI hides a confirmed deletion while ordinary sync converges. Authoritative diff --git a/internal/platform/client.go b/internal/platform/client.go index c846c4a2ff..14743fdad5 100644 --- a/internal/platform/client.go +++ b/internal/platform/client.go @@ -117,6 +117,20 @@ type CIReader interface { ListCIChecks(ctx context.Context, ref RepoRef, sha string) ([]CICheck, error) } +type WorkflowCatalogReader interface { + ListManualWorkflows(context.Context, RepoRef) ([]WorkflowDefinition, error) + ListWorkflowEnvironments(context.Context, RepoRef) ([]WorkflowEnvironment, error) +} + +type WorkflowRunReader interface { + ListWorkflowRuns(context.Context, RepoRef, WorkflowRunQuery) (Page[WorkflowRun], error) + ListWorkflowRunJobs(context.Context, RepoRef, string) ([]WorkflowRunJob, error) +} + +type WorkflowDispatcher interface { + DispatchWorkflow(context.Context, RepoRef, WorkflowDispatchRequest) (WorkflowDispatchResult, error) +} + type CommentMutator interface { CreateMergeRequestComment( ctx context.Context, diff --git a/internal/platform/registry.go b/internal/platform/registry.go index e2442b20ad..77f6807092 100644 --- a/internal/platform/registry.go +++ b/internal/platform/registry.go @@ -290,6 +290,45 @@ func (r *Registry) CIReader(kind Kind, host string) (CIReader, error) { return reader, nil } +func (r *Registry) WorkflowCatalogReader(kind Kind, host string) (WorkflowCatalogReader, error) { + provider, err := r.Provider(kind, host) + if err != nil { + return nil, err + } + + reader, ok := provider.(WorkflowCatalogReader) + if !ok || !provider.Capabilities().ReadWorkflows { + return nil, UnsupportedCapability(kind, host, "read_workflows") + } + return reader, nil +} + +func (r *Registry) WorkflowRunReader(kind Kind, host string) (WorkflowRunReader, error) { + provider, err := r.Provider(kind, host) + if err != nil { + return nil, err + } + + reader, ok := provider.(WorkflowRunReader) + if !ok || !provider.Capabilities().ReadWorkflowRuns { + return nil, UnsupportedCapability(kind, host, "read_workflow_runs") + } + return reader, nil +} + +func (r *Registry) WorkflowDispatcher(kind Kind, host string) (WorkflowDispatcher, error) { + provider, err := r.Provider(kind, host) + if err != nil { + return nil, err + } + + dispatcher, ok := provider.(WorkflowDispatcher) + if !ok || !provider.Capabilities().WorkflowDispatch { + return nil, UnsupportedCapability(kind, host, "workflow_dispatch") + } + return dispatcher, nil +} + func (r *Registry) CommentMutator(kind Kind, host string) (CommentMutator, error) { provider, err := r.Provider(kind, host) if err != nil { diff --git a/internal/platform/registry_test.go b/internal/platform/registry_test.go index aa2e74e0ea..c6eefae5d9 100644 --- a/internal/platform/registry_test.go +++ b/internal/platform/registry_test.go @@ -42,6 +42,40 @@ func (p testRepositoryReader) ListRepositories( return nil, nil } +type testWorkflowProvider struct { + testProvider +} + +func (p testWorkflowProvider) ListManualWorkflows( + context.Context, RepoRef, +) ([]WorkflowDefinition, error) { + return nil, nil +} + +func (p testWorkflowProvider) ListWorkflowEnvironments( + context.Context, RepoRef, +) ([]WorkflowEnvironment, error) { + return nil, nil +} + +func (p testWorkflowProvider) ListWorkflowRuns( + context.Context, RepoRef, WorkflowRunQuery, +) (Page[WorkflowRun], error) { + return Page[WorkflowRun]{}, nil +} + +func (p testWorkflowProvider) ListWorkflowRunJobs( + context.Context, RepoRef, string, +) ([]WorkflowRunJob, error) { + return nil, nil +} + +func (p testWorkflowProvider) DispatchWorkflow( + context.Context, RepoRef, WorkflowDispatchRequest, +) (WorkflowDispatchResult, error) { + return WorkflowDispatchResult{}, nil +} + func TestRegistryLooksUpProvidersByKindAndHost(t *testing.T) { provider := testProvider{ kind: KindGitLab, @@ -122,6 +156,82 @@ func TestRegistryFindsOptionalRepositoryReader(t *testing.T) { assert.Equal(t, Repository{}, repo) } +func TestRegistryFindsWorkflowCapabilities(t *testing.T) { + require := require.New(t) + provider := testWorkflowProvider{testProvider: testProvider{ + kind: KindGitLab, + host: "gitlab.com", + caps: Capabilities{ + ReadWorkflows: true, + ReadWorkflowRuns: true, + WorkflowDispatch: true, + }, + }} + registry, err := NewRegistry(provider) + require.NoError(err) + + catalogReader, err := registry.WorkflowCatalogReader(KindGitLab, "gitlab.com") + require.NoError(err) + runReader, err := registry.WorkflowRunReader(KindGitLab, "gitlab.com") + require.NoError(err) + dispatcher, err := registry.WorkflowDispatcher(KindGitLab, "gitlab.com") + require.NoError(err) + + assert.Equal(t, provider, catalogReader) + assert.Equal(t, provider, runReader) + assert.Equal(t, provider, dispatcher) +} + +func TestRegistryReturnsUnsupportedCapabilityForMissingWorkflowCapabilities(t *testing.T) { + require := require.New(t) + registry, err := NewRegistry(testWorkflowProvider{testProvider: testProvider{ + kind: KindGitLab, + host: "gitlab.com", + }}) + require.NoError(err) + + tests := []struct { + name string + access func() error + capability string + }{ + { + name: "workflow catalog", + access: func() error { + _, err := registry.WorkflowCatalogReader(KindGitLab, "gitlab.com") + return err + }, + capability: "read_workflows", + }, + { + name: "workflow runs", + access: func() error { + _, err := registry.WorkflowRunReader(KindGitLab, "gitlab.com") + return err + }, + capability: "read_workflow_runs", + }, + { + name: "workflow dispatch", + access: func() error { + _, err := registry.WorkflowDispatcher(KindGitLab, "gitlab.com") + return err + }, + capability: "workflow_dispatch", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := tc.access() + + var platformErr *Error + require.ErrorAs(err, &platformErr) + require.ErrorIs(err, ErrUnsupportedCapability) + assert.Equal(t, tc.capability, platformErr.Capability) + }) + } +} + func TestRegistryReturnsUnsupportedCapabilityForMissingOptionalReader(t *testing.T) { registry, err := NewRegistry(testProvider{ kind: KindGitLab, diff --git a/internal/platform/types.go b/internal/platform/types.go index 88741f066a..432cd414b3 100644 --- a/internal/platform/types.go +++ b/internal/platform/types.go @@ -505,6 +505,99 @@ func archiveContractError(kind Kind, host, field, format string, args ...any) er return ProviderContract(kind, host, field, fmt.Errorf(format, args...)) } +type WorkflowInputType string + +const ( + WorkflowInputString WorkflowInputType = "string" + WorkflowInputNumber WorkflowInputType = "number" + WorkflowInputBoolean WorkflowInputType = "boolean" + WorkflowInputChoice WorkflowInputType = "choice" + WorkflowInputEnvironment WorkflowInputType = "environment" +) + +type WorkflowInput struct { + Name string + Description string + Required bool + Type WorkflowInputType + Default any + HasDefault bool + Options []string +} + +type WorkflowDefinition struct { + ID string + Name string + Path string + State string + WebURL string + DefinitionSHA string + Inputs []WorkflowInput + Available bool + UnavailableReason string +} + +type WorkflowEnvironment struct { + Name string +} + +type WorkflowRunQuery struct { + WorkflowID string + Event string + Branch string + Cursor string + PerPage int +} + +type WorkflowRunStep struct { + Number int + Name string + Status string + Conclusion string + StartedAt time.Time + CompletedAt time.Time +} + +type WorkflowRunJob struct { + ID string + Name string + Status string + Conclusion string + StartedAt time.Time + CompletedAt time.Time + WebURL string + Steps []WorkflowRunStep +} + +type WorkflowRun struct { + ID string + WorkflowID string + RunNumber int64 + Name string + Event string + Ref string + HeadSHA string + Actor string + Status string + Conclusion string + CreatedAt time.Time + UpdatedAt time.Time + WebURL string +} + +type WorkflowDispatchRequest struct { + WorkflowID string + Ref string + Inputs map[string]any + ExpectedDefinitionSHA string +} + +type WorkflowDispatchResult struct { + Accepted bool + LocatingRun bool + Run *WorkflowRun +} + type Capabilities struct { ReadRepositories bool ReadMergeRequests bool @@ -517,6 +610,8 @@ type Capabilities struct { ReadMarkdownImages bool ReadAuthenticatedUser bool ReadNotifications bool + ReadWorkflows bool + ReadWorkflowRuns bool CommentMutation bool // StateMutation means the provider can PATCH the item itself: // open/close state transitions AND title/body/content updates. @@ -529,6 +624,7 @@ type Capabilities struct { MergeMutation bool ReviewMutation bool WorkflowApproval bool + WorkflowDispatch bool ReadyForReview bool DraftMutation bool IssueMutation bool diff --git a/internal/server/httpapi/repository_resolver.go b/internal/server/httpapi/repository_resolver.go index b188576986..372ceb5ded 100644 --- a/internal/server/httpapi/repository_resolver.go +++ b/internal/server/httpapi/repository_resolver.go @@ -116,6 +116,8 @@ func CapabilityEnabled(caps ProviderCapabilitiesResponse, capability string) boo return caps.ReviewMutation case "workflow_approval": return caps.WorkflowApproval + case "workflow_dispatch": + return caps.WorkflowDispatch case "ready_for_review": return caps.ReadyForReview case "draft_mutation": @@ -126,6 +128,10 @@ func CapabilityEnabled(caps ProviderCapabilitiesResponse, capability string) boo return caps.ReadLabels case "read_markdown_images": return caps.ReadMarkdownImages + case "read_workflows": + return caps.ReadWorkflows + case "read_workflow_runs": + return caps.ReadWorkflowRuns case "label_mutation": return caps.LabelMutation case "assignee_mutation": @@ -365,6 +371,8 @@ func ProviderCapabilitiesFromPlatform(caps platform.Capabilities) ProviderCapabi ReadComments: caps.ReadComments, ReadReleases: caps.ReadReleases, ReadCI: caps.ReadCI, + ReadWorkflows: caps.ReadWorkflows, + ReadWorkflowRuns: caps.ReadWorkflowRuns, ReadLabels: caps.ReadLabels, ReadMarkdownImages: caps.ReadMarkdownImages, ReadAuthenticatedUser: caps.ReadAuthenticatedUser, @@ -373,6 +381,7 @@ func ProviderCapabilitiesFromPlatform(caps platform.Capabilities) ProviderCapabi MergeMutation: caps.MergeMutation, ReviewMutation: caps.ReviewMutation, WorkflowApproval: caps.WorkflowApproval, + WorkflowDispatch: caps.WorkflowDispatch, ReadyForReview: caps.ReadyForReview, DraftMutation: caps.DraftMutation, IssueMutation: caps.IssueMutation, diff --git a/internal/server/httpapi/repository_resolver_test.go b/internal/server/httpapi/repository_resolver_test.go index 79c1752dae..feea7a172a 100644 --- a/internal/server/httpapi/repository_resolver_test.go +++ b/internal/server/httpapi/repository_resolver_test.go @@ -2,6 +2,7 @@ package httpapi import ( "context" + "encoding/json" "errors" "testing" "time" @@ -39,6 +40,23 @@ func TestRepositoryResolverOwnsCapabilityFallbackPolicy(t *testing.T) { assert.False(gitlab.MergeMutation) } +func TestProviderCapabilitiesFromPlatformMapsWorkflowWireFields(t *testing.T) { + response := ProviderCapabilitiesFromPlatform(platform.Capabilities{ + ReadWorkflows: true, + ReadWorkflowRuns: true, + WorkflowDispatch: true, + }) + + encoded, err := json.Marshal(response) + require.NoError(t, err) + var fields map[string]any + require.NoError(t, json.Unmarshal(encoded, &fields)) + + assert.Equal(t, true, fields["read_workflows"]) + assert.Equal(t, true, fields["read_workflow_runs"]) + assert.Equal(t, true, fields["workflow_dispatch"]) +} + func TestRepositoryResolverBuildsCanonicalRef(t *testing.T) { assert := assert.New(t) require := require.New(t) diff --git a/internal/server/httpapi/repository_types.go b/internal/server/httpapi/repository_types.go index 25c0e45092..deb58ebe65 100644 --- a/internal/server/httpapi/repository_types.go +++ b/internal/server/httpapi/repository_types.go @@ -8,6 +8,8 @@ type ProviderCapabilitiesResponse struct { ReadComments bool `json:"read_comments"` ReadReleases bool `json:"read_releases"` ReadCI bool `json:"read_ci"` + ReadWorkflows bool `json:"read_workflows"` + ReadWorkflowRuns bool `json:"read_workflow_runs"` ReadLabels bool `json:"read_labels"` ReadMarkdownImages bool `json:"read_markdown_images"` ReadAuthenticatedUser bool `json:"read_authenticated_user"` @@ -16,6 +18,7 @@ type ProviderCapabilitiesResponse struct { MergeMutation bool `json:"merge_mutation"` ReviewMutation bool `json:"review_mutation"` WorkflowApproval bool `json:"workflow_approval"` + WorkflowDispatch bool `json:"workflow_dispatch"` ReadyForReview bool `json:"ready_for_review"` DraftMutation bool `json:"draft_mutation"` IssueMutation bool `json:"issue_mutation"` @@ -60,6 +63,7 @@ type RepoOperations struct { CloseIssue OperationAvailability `json:"close_issue"` ReopenIssue OperationAvailability `json:"reopen_issue"` ApproveWorkflow OperationAvailability `json:"approve_workflow"` + DispatchWorkflow OperationAvailability `json:"dispatch_workflow"` UpdateContent OperationAvailability `json:"update_content"` ReplyReviewThread OperationAvailability `json:"reply_review_thread"` ResolveReviewThread OperationAvailability `json:"resolve_review_thread"` diff --git a/internal/server/operation_availability.go b/internal/server/operation_availability.go index 2262e6a7b1..d4aeee5e6d 100644 --- a/internal/server/operation_availability.go +++ b/internal/server/operation_availability.go @@ -19,6 +19,7 @@ const ( capabilityMergeMutation = "merge_mutation" capabilityReviewMutation = "review_mutation" capabilityWorkflowApproval = "workflow_approval" + capabilityWorkflowDispatch = "workflow_dispatch" capabilityReadyForReview = "ready_for_review" capabilityDraftMutation = "draft_mutation" capabilityIssueMutation = "issue_mutation" @@ -58,6 +59,7 @@ const ( operationCloseIssue = "close_issue" operationReopenIssue = "reopen_issue" operationApproveWorkflow = "approve_workflow" + operationDispatchWorkflow = "dispatch_workflow" operationUpdateContent = "update_content" operationReplyReviewThread = "reply_review_thread" operationResolveReviewThread = "resolve_review_thread" @@ -142,6 +144,7 @@ var ( descCloseIssue = operationDescriptor{name: operationCloseIssue, requiredCapabilities: []string{capabilityIssueMutation}, bucket: apiBucketREST} descReopenIssue = operationDescriptor{name: operationReopenIssue, requiredCapabilities: []string{capabilityIssueMutation}, bucket: apiBucketREST} descApproveWorkflow = operationDescriptor{name: operationApproveWorkflow, requiredCapabilities: []string{capabilityWorkflowApproval}, bucket: apiBucketREST} + descDispatchWorkflow = operationDescriptor{name: operationDispatchWorkflow, requiredCapabilities: []string{capabilityWorkflowDispatch}, bucket: apiBucketREST} // Content edits (PR/issue title, body, task-list writes) ride the // state-mutation capability: state_mutation has always meant "can // PATCH the item" across providers — state transitions and @@ -205,6 +208,7 @@ func (s *Server) repoOperationsWithContext( CloseIssue: derive(descCloseIssue), ReopenIssue: derive(descReopenIssue), ApproveWorkflow: derive(descApproveWorkflow), + DispatchWorkflow: derive(descDispatchWorkflow), UpdateContent: derive(descUpdateContent), ReplyReviewThread: derive(descReplyReviewThread), ResolveReviewThread: derive(descResolveReviewThread), diff --git a/internal/server/operation_availability_test.go b/internal/server/operation_availability_test.go index 90dbd81fba..f693538167 100644 --- a/internal/server/operation_availability_test.go +++ b/internal/server/operation_availability_test.go @@ -36,6 +36,7 @@ func TestDeriveOperationAvailability(t *testing.T) { ReviewMutation: true, ReviewDraftMutation: true, WorkflowApproval: true, + WorkflowDispatch: true, ReadyForReview: true, DraftMutation: true, IssueMutation: true, @@ -82,6 +83,57 @@ func TestDeriveOperationAvailability(t *testing.T) { repo: repoCanMerge, expected: httpapi.OperationAvailability{Available: true}, }, + { + name: "dispatch_workflow is unavailable without workflow_dispatch", + op: descDispatchWorkflow, + caps: func() httpapi.ProviderCapabilitiesResponse { + c := allCaps + c.WorkflowDispatch = false + return c + }(), + repo: repoCanMerge, + expected: httpapi.OperationAvailability{ + Code: availabilityCodeUnsupportedCapability, + UnavailableReason: "Provider does not support workflow_dispatch", + RequiredCapability: capabilityWorkflowDispatch, + }, + }, + { + name: "dispatch_workflow is unavailable without a write credential", + op: descDispatchWorkflow, + caps: allCaps, + repo: repoCanMerge, + writeCred: writeCredentialGate{ + code: availabilityCodeMissingWriteCredential, + reason: "No user credential for writes on github.com", + }, + expected: httpapi.OperationAvailability{ + Code: availabilityCodeMissingWriteCredential, + UnavailableReason: "No user credential for writes on github.com", + }, + }, + { + name: "dispatch_workflow is unavailable during a REST rate-limit window", + op: descDispatchWorkflow, + caps: allCaps, + repo: repoCanMerge, + rate: operationRateLimitForBuckets( + descDispatchWorkflow.rateLimitBuckets(), + map[apiBucket]rateLimitAvailability{apiBucketREST: limitedRate}, + ), + expected: httpapi.OperationAvailability{ + Code: availabilityCodeRateLimited, + UnavailableReason: "github.com rate-limited", + RetryAt: resetAt.UTC().Format(time.RFC3339), + }, + }, + { + name: "dispatch_workflow is available when all gates pass", + op: descDispatchWorkflow, + caps: allCaps, + repo: repoCanMerge, + expected: httpapi.OperationAvailability{Available: true}, + }, { name: "missing required capability surfaces unsupported_capability", op: mergePR, @@ -319,6 +371,7 @@ func TestRepoOperationsWireShape(t *testing.T) { "close_issue", "reopen_issue", "approve_workflow", + "dispatch_workflow", "update_content", "reply_review_thread", "resolve_review_thread", From cf1129ced28c148d58d0b6eeef02f404b1ea670d Mon Sep 17 00:00:00 2001 From: Max Mill Date: Fri, 28 Aug 2026 15:39:01 +0200 Subject: [PATCH 02/41] chore: regenerate provider workflow API contracts --- frontend/openapi/openapi.yaml | 12 ++++++++++++ frontend/src/lib/api/generated/schema.ts | 4 ++++ internal/apiclient/generated/client.gen.go | 4 ++++ 3 files changed, 20 insertions(+) diff --git a/frontend/openapi/openapi.yaml b/frontend/openapi/openapi.yaml index 4ea31c24ec..3723ec04c9 100644 --- a/frontend/openapi/openapi.yaml +++ b/frontend/openapi/openapi.yaml @@ -5138,6 +5138,10 @@ components: type: boolean read_review_threads: type: boolean + read_workflow_runs: + type: boolean + read_workflows: + type: boolean ready_for_review: type: boolean review_draft_mutation: @@ -5164,6 +5168,8 @@ components: type: boolean workflow_approval: type: boolean + workflow_dispatch: + type: boolean required: - read_repositories - read_merge_requests @@ -5172,6 +5178,8 @@ components: - read_comments - read_releases - read_ci + - read_workflows + - read_workflow_runs - read_labels - read_markdown_images - read_authenticated_user @@ -5180,6 +5188,7 @@ components: - merge_mutation - review_mutation - workflow_approval + - workflow_dispatch - ready_for_review - draft_mutation - issue_mutation @@ -6136,6 +6145,8 @@ components: $ref: "#/components/schemas/OperationAvailability" delete_comment: $ref: "#/components/schemas/OperationAvailability" + dispatch_workflow: + $ref: "#/components/schemas/OperationAvailability" edit_comment: $ref: "#/components/schemas/OperationAvailability" mark_draft: @@ -6183,6 +6194,7 @@ components: - close_issue - reopen_issue - approve_workflow + - dispatch_workflow - update_content - reply_review_thread - resolve_review_thread diff --git a/frontend/src/lib/api/generated/schema.ts b/frontend/src/lib/api/generated/schema.ts index a9f68edb24..bdc611a405 100644 --- a/frontend/src/lib/api/generated/schema.ts +++ b/frontend/src/lib/api/generated/schema.ts @@ -6975,6 +6975,8 @@ export interface components { read_releases: boolean; read_repositories: boolean; read_review_threads: boolean; + read_workflow_runs: boolean; + read_workflows: boolean; ready_for_review: boolean; review_draft_mutation: boolean; review_mutation: boolean; @@ -6986,6 +6988,7 @@ export interface components { thread_reply: boolean; thread_resolve: boolean; workflow_approval: boolean; + workflow_dispatch: boolean; }; PublishChange: { old_path?: string; @@ -7432,6 +7435,7 @@ export interface components { close_pr: components["schemas"]["OperationAvailability"]; create_issue: components["schemas"]["OperationAvailability"]; delete_comment: components["schemas"]["OperationAvailability"]; + dispatch_workflow: components["schemas"]["OperationAvailability"]; edit_comment: components["schemas"]["OperationAvailability"]; mark_draft: components["schemas"]["OperationAvailability"]; mark_ready_for_review: components["schemas"]["OperationAvailability"]; diff --git a/internal/apiclient/generated/client.gen.go b/internal/apiclient/generated/client.gen.go index e172c129d6..342cca5ef2 100644 --- a/internal/apiclient/generated/client.gen.go +++ b/internal/apiclient/generated/client.gen.go @@ -3435,6 +3435,8 @@ type ProviderCapabilitiesResponse struct { ReadReleases bool `json:"read_releases"` ReadRepositories bool `json:"read_repositories"` ReadReviewThreads bool `json:"read_review_threads"` + ReadWorkflowRuns bool `json:"read_workflow_runs"` + ReadWorkflows bool `json:"read_workflows"` ReadyForReview bool `json:"ready_for_review"` ReviewDraftMutation bool `json:"review_draft_mutation"` ReviewMutation bool `json:"review_mutation"` @@ -3446,6 +3448,7 @@ type ProviderCapabilitiesResponse struct { ThreadReply bool `json:"thread_reply"` ThreadResolve bool `json:"thread_resolve"` WorkflowApproval bool `json:"workflow_approval"` + WorkflowDispatch bool `json:"workflow_dispatch"` } // PublishChange defines model for PublishChange. @@ -3897,6 +3900,7 @@ type RepoOperations struct { ClosePr OperationAvailability `json:"close_pr"` CreateIssue OperationAvailability `json:"create_issue"` DeleteComment OperationAvailability `json:"delete_comment"` + DispatchWorkflow OperationAvailability `json:"dispatch_workflow"` EditComment OperationAvailability `json:"edit_comment"` MarkDraft OperationAvailability `json:"mark_draft"` MarkReadyForReview OperationAvailability `json:"mark_ready_for_review"` From de891f70858ef4e82c65bfadeceda198afe45671 Mon Sep 17 00:00:00 2001 From: Max Mill Date: Fri, 28 Aug 2026 15:48:08 +0200 Subject: [PATCH 03/41] feat: normalize manual workflow definitions A bounded node parser preserves GitHub workflow input types and declaration order while rejecting ambiguous schemas before they reach dispatch forms. --- .../platform/github/workflow_definition.go | 380 ++++++++++++++++++ .../github/workflow_definition_test.go | 235 +++++++++++ 2 files changed, 615 insertions(+) create mode 100644 internal/platform/github/workflow_definition.go create mode 100644 internal/platform/github/workflow_definition_test.go diff --git a/internal/platform/github/workflow_definition.go b/internal/platform/github/workflow_definition.go new file mode 100644 index 0000000000..49a38d2816 --- /dev/null +++ b/internal/platform/github/workflow_definition.go @@ -0,0 +1,380 @@ +package github + +import ( + "fmt" + "math" + + "go.kenn.io/forge/internal/platform" + "go.yaml.in/yaml/v3" +) + +const MaxWorkflowDefinitionBytes = 1 << 20 + +func ParseManualWorkflow( + name string, + path string, + webURL string, + definitionSHA string, + content []byte, +) (platform.WorkflowDefinition, bool, error) { + definition := platform.WorkflowDefinition{ + ID: path, + Name: name, + Path: path, + WebURL: webURL, + DefinitionSHA: definitionSHA, + Available: true, + } + if len(content) > MaxWorkflowDefinitionBytes { + return definition, false, fmt.Errorf( + "workflow definition exceeds %d-byte limit", + MaxWorkflowDefinitionBytes, + ) + } + + var document yaml.Node + if err := yaml.Unmarshal(content, &document); err != nil { + return definition, false, fmt.Errorf("parse workflow definition: %w", err) + } + if len(document.Content) != 1 { + return definition, false, fmt.Errorf("workflow definition must contain one document") + } + root := document.Content[0] + if err := rejectAliases(root); err != nil { + return definition, false, err + } + if root.Kind != yaml.MappingNode { + return definition, false, fmt.Errorf("workflow definition must be a mapping") + } + + onNode, found, err := mappingValue(root, "on") + if err != nil { + return definition, false, fmt.Errorf("parse workflow triggers: %w", err) + } + if !found { + return definition, false, nil + } + + manualNode, manual, err := manualTrigger(onNode) + if err != nil { + return definition, false, err + } + if !manual { + return definition, false, nil + } + if manualNode == nil || isNull(manualNode) { + return definition, true, nil + } + if manualNode.Kind != yaml.MappingNode { + return definition, false, fmt.Errorf("workflow_dispatch configuration must be a mapping") + } + + inputsNode, found, err := mappingValue(manualNode, "inputs") + if err != nil { + return definition, false, fmt.Errorf("parse workflow_dispatch: %w", err) + } + if !found || isNull(inputsNode) { + return definition, true, nil + } + inputs, err := parseWorkflowInputs(inputsNode) + if err != nil { + return definition, false, err + } + definition.Inputs = inputs + return definition, true, nil +} + +func rejectAliases(root *yaml.Node) error { + stack := []*yaml.Node{root} + for len(stack) > 0 { + last := len(stack) - 1 + node := stack[last] + stack = stack[:last] + if node.Kind == yaml.AliasNode { + return fmt.Errorf("workflow definition must not contain YAML aliases") + } + stack = append(stack, node.Content...) + } + return nil +} + +func mappingValue(mapping *yaml.Node, wanted string) (*yaml.Node, bool, error) { + if mapping.Kind != yaml.MappingNode { + return nil, false, fmt.Errorf("expected mapping") + } + var value *yaml.Node + found := false + seen := make(map[string]struct{}, len(mapping.Content)/2) + for index := 0; index < len(mapping.Content); index += 2 { + key := mapping.Content[index] + if key.Kind != yaml.ScalarNode || key.Tag != "!!str" { + return nil, false, fmt.Errorf("mapping keys must be strings") + } + if _, duplicate := seen[key.Value]; duplicate { + return nil, false, fmt.Errorf("duplicate key %q", key.Value) + } + seen[key.Value] = struct{}{} + if key.Value == wanted { + value = mapping.Content[index+1] + found = true + } + } + return value, found, nil +} + +func manualTrigger(onNode *yaml.Node) (*yaml.Node, bool, error) { + switch onNode.Kind { + case yaml.ScalarNode: + if isNull(onNode) { + return nil, false, nil + } + if onNode.Tag != "!!str" { + return nil, false, fmt.Errorf("workflow trigger must be a string") + } + return nil, onNode.Value == "workflow_dispatch", nil + case yaml.SequenceNode: + manual := false + for _, event := range onNode.Content { + if event.Kind != yaml.ScalarNode || event.Tag != "!!str" { + return nil, false, fmt.Errorf("workflow trigger sequence must contain strings") + } + if event.Value == "workflow_dispatch" { + manual = true + } + } + return nil, manual, nil + case yaml.MappingNode: + manualNode, found, err := mappingValue(onNode, "workflow_dispatch") + if err != nil { + return nil, false, fmt.Errorf("parse workflow trigger mapping: %w", err) + } + return manualNode, found, nil + default: + return nil, false, fmt.Errorf("workflow trigger must be a scalar, sequence, or mapping") + } +} + +func parseWorkflowInputs(inputsNode *yaml.Node) ([]platform.WorkflowInput, error) { + if inputsNode.Kind != yaml.MappingNode { + return nil, fmt.Errorf("workflow_dispatch inputs must be a mapping") + } + + inputs := make([]platform.WorkflowInput, 0, len(inputsNode.Content)/2) + seen := make(map[string]struct{}, len(inputsNode.Content)/2) + for index := 0; index < len(inputsNode.Content); index += 2 { + nameNode := inputsNode.Content[index] + definitionNode := inputsNode.Content[index+1] + if nameNode.Kind != yaml.ScalarNode || nameNode.Tag != "!!str" { + return nil, fmt.Errorf("workflow input names must be strings") + } + if _, duplicate := seen[nameNode.Value]; duplicate { + return nil, fmt.Errorf("duplicate workflow input %q", nameNode.Value) + } + seen[nameNode.Value] = struct{}{} + + input, err := parseWorkflowInput(nameNode.Value, definitionNode) + if err != nil { + return nil, fmt.Errorf("parse workflow input %q: %w", nameNode.Value, err) + } + inputs = append(inputs, input) + } + return inputs, nil +} + +func parseWorkflowInput(name string, definitionNode *yaml.Node) (platform.WorkflowInput, error) { + input := platform.WorkflowInput{Name: name, Type: platform.WorkflowInputString} + if isNull(definitionNode) { + return input, nil + } + if definitionNode.Kind != yaml.MappingNode { + return input, fmt.Errorf("definition must be a mapping") + } + + fields, err := inputFields(definitionNode) + if err != nil { + return input, err + } + if node, ok := fields["description"]; ok { + value, err := stringScalar(node, "description") + if err != nil { + return input, err + } + input.Description = value + } + if node, ok := fields["required"]; ok { + if node.Kind != yaml.ScalarNode || node.Tag != "!!bool" { + return input, fmt.Errorf("required must be a boolean") + } + if err := node.Decode(&input.Required); err != nil { + return input, fmt.Errorf("decode required: %w", err) + } + } + if node, ok := fields["type"]; ok { + value, err := stringScalar(node, "type") + if err != nil { + return input, err + } + input.Type = platform.WorkflowInputType(value) + } + if !supportedWorkflowInputType(input.Type) { + return input, fmt.Errorf("unsupported type %q", input.Type) + } + + optionsNode, hasOptions := fields["options"] + if input.Type == platform.WorkflowInputChoice { + if !hasOptions { + return input, fmt.Errorf("choice input requires options") + } + options, err := choiceOptions(optionsNode) + if err != nil { + return input, err + } + input.Options = options + } else if hasOptions { + return input, fmt.Errorf("options are only valid for choice inputs") + } + + if defaultNode, ok := fields["default"]; ok { + value, err := scalarDefault(defaultNode) + if err != nil { + return input, err + } + if !defaultMatchesType(value, input.Type) { + return input, fmt.Errorf("default does not match type %q", input.Type) + } + if input.Type == platform.WorkflowInputChoice && !contains(input.Options, value.(string)) { + return input, fmt.Errorf("default %q is not one of the choice options", value) + } + input.Default = value + input.HasDefault = true + } + return input, nil +} + +func inputFields(mapping *yaml.Node) (map[string]*yaml.Node, error) { + fields := make(map[string]*yaml.Node, len(mapping.Content)/2) + for index := 0; index < len(mapping.Content); index += 2 { + key := mapping.Content[index] + if key.Kind != yaml.ScalarNode || key.Tag != "!!str" { + return nil, fmt.Errorf("definition keys must be strings") + } + if _, duplicate := fields[key.Value]; duplicate { + return nil, fmt.Errorf("duplicate definition key %q", key.Value) + } + fields[key.Value] = mapping.Content[index+1] + } + return fields, nil +} + +func supportedWorkflowInputType(inputType platform.WorkflowInputType) bool { + switch inputType { + case platform.WorkflowInputString, + platform.WorkflowInputNumber, + platform.WorkflowInputBoolean, + platform.WorkflowInputChoice, + platform.WorkflowInputEnvironment: + return true + default: + return false + } +} + +func choiceOptions(optionsNode *yaml.Node) ([]string, error) { + if optionsNode.Kind != yaml.SequenceNode { + return nil, fmt.Errorf("choice options must be a sequence") + } + if len(optionsNode.Content) == 0 { + return nil, fmt.Errorf("choice input requires at least one option") + } + + options := make([]string, 0, len(optionsNode.Content)) + seen := make(map[string]struct{}, len(optionsNode.Content)) + for _, optionNode := range optionsNode.Content { + option, err := stringScalar(optionNode, "choice option") + if err != nil { + return nil, err + } + if _, duplicate := seen[option]; duplicate { + return nil, fmt.Errorf("duplicate choice option %q", option) + } + seen[option] = struct{}{} + options = append(options, option) + } + return options, nil +} + +func scalarDefault(node *yaml.Node) (any, error) { + if node.Kind != yaml.ScalarNode { + return nil, fmt.Errorf("default must be a scalar") + } + + switch node.Tag { + case "!!str": + return node.Value, nil + case "!!bool": + var value bool + if err := node.Decode(&value); err != nil { + return nil, fmt.Errorf("decode boolean default: %w", err) + } + return value, nil + case "!!int": + var value any + if err := node.Decode(&value); err != nil { + return nil, fmt.Errorf("decode integer default: %w", err) + } + return value, nil + case "!!float": + var value float64 + if err := node.Decode(&value); err != nil { + return nil, fmt.Errorf("decode number default: %w", err) + } + if math.IsInf(value, 0) || math.IsNaN(value) { + return nil, fmt.Errorf("number default must be finite") + } + return value, nil + default: + return nil, fmt.Errorf("default has unsupported scalar type %q", node.Tag) + } +} + +func defaultMatchesType(value any, inputType platform.WorkflowInputType) bool { + switch inputType { + case platform.WorkflowInputString, platform.WorkflowInputChoice, platform.WorkflowInputEnvironment: + _, ok := value.(string) + return ok + case platform.WorkflowInputBoolean: + _, ok := value.(bool) + return ok + case platform.WorkflowInputNumber: + switch value.(type) { + case int, int8, int16, int32, int64, + uint, uint8, uint16, uint32, uint64, + float32, float64: + return true + default: + return false + } + default: + return false + } +} + +func stringScalar(node *yaml.Node, field string) (string, error) { + if node.Kind != yaml.ScalarNode || node.Tag != "!!str" { + return "", fmt.Errorf("%s must be a string", field) + } + return node.Value, nil +} + +func isNull(node *yaml.Node) bool { + return node.Kind == yaml.ScalarNode && node.Tag == "!!null" +} + +func contains(values []string, wanted string) bool { + for _, value := range values { + if value == wanted { + return true + } + } + return false +} diff --git a/internal/platform/github/workflow_definition_test.go b/internal/platform/github/workflow_definition_test.go new file mode 100644 index 0000000000..e11f205509 --- /dev/null +++ b/internal/platform/github/workflow_definition_test.go @@ -0,0 +1,235 @@ +package github + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.kenn.io/forge/internal/platform" +) + +func TestParseManualWorkflow(t *testing.T) { + t.Run("preserves metadata and typed inputs in declaration order", func(t *testing.T) { + content := []byte(`name: Release +on: + workflow_dispatch: + inputs: + version: + description: Release version + required: true + type: string + dry_run: + description: Do not publish + required: true + default: false + type: boolean + retries: + default: 2 + type: number + channel: + default: stable + type: choice + options: [stable, beta] + environment: + required: true + type: environment +`) + + definition, manual, err := ParseManualWorkflow( + "Release", + ".github/workflows/release.yml", + "https://github.com/acme/widget/actions/workflows/release.yml", + "0123456789abcdef", + content, + ) + require.NoError(t, err) + require.True(t, manual) + assert.Equal(t, platform.WorkflowDefinition{ + ID: ".github/workflows/release.yml", + Name: "Release", + Path: ".github/workflows/release.yml", + WebURL: "https://github.com/acme/widget/actions/workflows/release.yml", + DefinitionSHA: "0123456789abcdef", + Inputs: []platform.WorkflowInput{ + { + Name: "version", + Description: "Release version", + Required: true, + Type: platform.WorkflowInputString, + }, + { + Name: "dry_run", + Description: "Do not publish", + Required: true, + Type: platform.WorkflowInputBoolean, + Default: false, + HasDefault: true, + }, + { + Name: "retries", + Type: platform.WorkflowInputNumber, + Default: 2, + HasDefault: true, + }, + { + Name: "channel", + Type: platform.WorkflowInputChoice, + Default: "stable", + HasDefault: true, + Options: []string{"stable", "beta"}, + }, + { + Name: "environment", + Required: true, + Type: platform.WorkflowInputEnvironment, + }, + }, + Available: true, + }, definition) + }) + + for _, test := range []struct { + name string + content string + manual bool + inputs []platform.WorkflowInput + }{ + { + name: "scalar trigger keeps on as a YAML string key", + content: "on: workflow_dispatch\n", + manual: true, + }, + { + name: "sequence trigger", + content: "on: [push, workflow_dispatch]\n", + manual: true, + }, + { + name: "mapping without workflow dispatch", + content: "on:\n push:\n branches: [main]\n", + manual: false, + }, + { + name: "manual mapping without inputs", + content: "on:\n workflow_dispatch:\n", + manual: true, + }, + { + name: "missing type defaults to string", + content: "on:\n workflow_dispatch:\n inputs:\n label:\n" + + " description: Build label\n default: candidate\n", + manual: true, + inputs: []platform.WorkflowInput{{ + Name: "label", + Description: "Build label", + Type: platform.WorkflowInputString, + Default: "candidate", + HasDefault: true, + }}, + }, + } { + t.Run(test.name, func(t *testing.T) { + definition, manual, err := ParseManualWorkflow( + "CI", ".github/workflows/ci.yml", "https://example.test/ci", "sha", []byte(test.content), + ) + require.NoError(t, err) + assert.Equal(t, test.manual, manual) + assert.Equal(t, test.inputs, definition.Inputs) + assert.Equal(t, ".github/workflows/ci.yml", definition.ID) + assert.Equal(t, "CI", definition.Name) + assert.True(t, definition.Available) + }) + } + + for _, test := range []struct { + name string + content []byte + }{ + { + name: "unsupported input type", + content: []byte("on:\n workflow_dispatch:\n inputs:\n target:\n type: object\n"), + }, + { + name: "duplicate input key", + content: []byte("on:\n workflow_dispatch:\n inputs:\n target:\n type: string\n target:\n type: string\n"), + }, + { + name: "choice without options", + content: []byte("on:\n workflow_dispatch:\n inputs:\n channel:\n type: choice\n"), + }, + { + name: "choice with empty options", + content: []byte("on:\n workflow_dispatch:\n inputs:\n channel:\n type: choice\n options: []\n"), + }, + { + name: "default outside choices", + content: []byte("on:\n workflow_dispatch:\n inputs:\n channel:\n type: choice\n options: [stable, beta]\n default: nightly\n"), + }, + { + name: "alias", + content: []byte("dispatch: &dispatch workflow_dispatch\non: *dispatch\n"), + }, + { + name: "mapping default", + content: []byte("on:\n workflow_dispatch:\n inputs:\n target:\n type: string\n default: {name: main}\n"), + }, + { + name: "sequence default", + content: []byte("on:\n workflow_dispatch:\n inputs:\n target:\n type: string\n default: [main]\n"), + }, + { + name: "scalar choice options", + content: []byte("on:\n workflow_dispatch:\n inputs:\n channel:\n type: choice\n options: stable\n"), + }, + { + name: "non scalar choice option", + content: []byte("on:\n workflow_dispatch:\n inputs:\n channel:\n type: choice\n options: [stable, {name: beta}]\n"), + }, + { + name: "boolean input with string default", + content: []byte("on:\n workflow_dispatch:\n inputs:\n dry_run:\n type: boolean\n default: no\n"), + }, + { + name: "number input with string default", + content: []byte("on:\n workflow_dispatch:\n inputs:\n retries:\n type: number\n default: two\n"), + }, + { + name: "string input with boolean default", + content: []byte("on:\n workflow_dispatch:\n inputs:\n target:\n type: string\n default: false\n"), + }, + { + name: "non boolean required", + content: []byte("on:\n workflow_dispatch:\n inputs:\n target:\n required: yes\n"), + }, + { + name: "non string description", + content: []byte("on:\n workflow_dispatch:\n inputs:\n target:\n description: 42\n"), + }, + { + name: "non mapping inputs", + content: []byte("on:\n workflow_dispatch:\n inputs: [target]\n"), + }, + { + name: "non mapping input definition", + content: []byte("on:\n workflow_dispatch:\n inputs:\n target: string\n"), + }, + { + name: "non scalar sequence trigger after workflow dispatch", + content: []byte("on: [workflow_dispatch, {push: null}]\n"), + }, + { + name: "malformed YAML", + content: []byte("on: [workflow_dispatch\n"), + }, + { + name: "payload larger than limit", + content: bytes.Repeat([]byte("x"), MaxWorkflowDefinitionBytes+1), + }, + } { + t.Run("rejects "+test.name, func(t *testing.T) { + _, _, err := ParseManualWorkflow("CI", "ci.yml", "https://example.test/ci", "sha", test.content) + require.Error(t, err) + }) + } +} From 939d656a5503143541898dfc186cb7bc2c67b906 Mon Sep 17 00:00:00 2001 From: Max Mill Date: Fri, 28 Aug 2026 15:55:55 +0200 Subject: [PATCH 04/41] fix: reject ambiguous workflow definitions Require a single YAML document and reject unknown workflow_dispatch or input-definition fields so malformed schemas cannot be silently normalized. --- .../platform/github/workflow_definition.go | 39 ++++++++++++++++++- .../github/workflow_definition_test.go | 16 ++++++++ 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/internal/platform/github/workflow_definition.go b/internal/platform/github/workflow_definition.go index 49a38d2816..b16764a7a9 100644 --- a/internal/platform/github/workflow_definition.go +++ b/internal/platform/github/workflow_definition.go @@ -1,7 +1,9 @@ package github import ( + "bytes" "fmt" + "io" "math" "go.kenn.io/forge/internal/platform" @@ -32,10 +34,18 @@ func ParseManualWorkflow( ) } + decoder := yaml.NewDecoder(bytes.NewReader(content)) var document yaml.Node - if err := yaml.Unmarshal(content, &document); err != nil { + if err := decoder.Decode(&document); err != nil { return definition, false, fmt.Errorf("parse workflow definition: %w", err) } + var trailing yaml.Node + if err := decoder.Decode(&trailing); err != io.EOF { + if err != nil { + return definition, false, fmt.Errorf("parse trailing workflow definition: %w", err) + } + return definition, false, fmt.Errorf("workflow definition must contain one document") + } if len(document.Content) != 1 { return definition, false, fmt.Errorf("workflow definition must contain one document") } @@ -69,7 +79,7 @@ func ParseManualWorkflow( return definition, false, fmt.Errorf("workflow_dispatch configuration must be a mapping") } - inputsNode, found, err := mappingValue(manualNode, "inputs") + inputsNode, found, err := workflowDispatchInputs(manualNode) if err != nil { return definition, false, fmt.Errorf("parse workflow_dispatch: %w", err) } @@ -122,6 +132,26 @@ func mappingValue(mapping *yaml.Node, wanted string) (*yaml.Node, bool, error) { return value, found, nil } +func workflowDispatchInputs(mapping *yaml.Node) (*yaml.Node, bool, error) { + var inputs *yaml.Node + found := false + for index := 0; index < len(mapping.Content); index += 2 { + key := mapping.Content[index] + if key.Kind != yaml.ScalarNode || key.Tag != "!!str" { + return nil, false, fmt.Errorf("workflow_dispatch keys must be strings") + } + if key.Value != "inputs" { + return nil, false, fmt.Errorf("unknown workflow_dispatch field %q", key.Value) + } + if found { + return nil, false, fmt.Errorf("duplicate workflow_dispatch field %q", key.Value) + } + inputs = mapping.Content[index+1] + found = true + } + return inputs, found, nil +} + func manualTrigger(onNode *yaml.Node) (*yaml.Node, bool, error) { switch onNode.Kind { case yaml.ScalarNode: @@ -258,6 +288,11 @@ func inputFields(mapping *yaml.Node) (map[string]*yaml.Node, error) { if key.Kind != yaml.ScalarNode || key.Tag != "!!str" { return nil, fmt.Errorf("definition keys must be strings") } + switch key.Value { + case "description", "required", "type", "default", "options": + default: + return nil, fmt.Errorf("unknown input definition field %q", key.Value) + } if _, duplicate := fields[key.Value]; duplicate { return nil, fmt.Errorf("duplicate definition key %q", key.Value) } diff --git a/internal/platform/github/workflow_definition_test.go b/internal/platform/github/workflow_definition_test.go index e11f205509..2df4acfc1a 100644 --- a/internal/platform/github/workflow_definition_test.go +++ b/internal/platform/github/workflow_definition_test.go @@ -218,6 +218,22 @@ on: name: "non scalar sequence trigger after workflow dispatch", content: []byte("on: [workflow_dispatch, {push: null}]\n"), }, + { + name: "multiple YAML documents", + content: []byte("on: workflow_dispatch\n---\nname: trailing\n"), + }, + { + name: "unknown workflow dispatch field", + content: []byte("on:\n workflow_dispatch:\n inputz: {}\n"), + }, + { + name: "misspelled required field", + content: []byte("on:\n workflow_dispatch:\n inputs:\n target:\n requred: true\n"), + }, + { + name: "misspelled default field", + content: []byte("on:\n workflow_dispatch:\n inputs:\n target:\n defualt: main\n"), + }, { name: "malformed YAML", content: []byte("on: [workflow_dispatch\n"), From 8026d3553706635e696e021333131dee038ed512 Mon Sep 17 00:00:00 2001 From: Max Mill Date: Fri, 28 Aug 2026 16:08:07 +0200 Subject: [PATCH 05/41] feat: implement GitHub workflow actions provider GitHub workflow discovery, runs, jobs, environments, and dispatch now normalize through the provider boundary and preserve personal-credential attribution for writes. --- internal/github/auth_router.go | 75 +++++++ internal/github/client.go | 116 +++++++++++ internal/github/client_test.go | 73 +++++++ internal/github/sync.go | 233 ++++++++++++++++++++++ internal/github/workflow_provider_test.go | 139 +++++++++++++ 5 files changed, 636 insertions(+) create mode 100644 internal/github/workflow_provider_test.go diff --git a/internal/github/auth_router.go b/internal/github/auth_router.go index e692d64d21..58c6b58d01 100644 --- a/internal/github/auth_router.go +++ b/internal/github/auth_router.go @@ -270,6 +270,9 @@ var ( _ githubAssigneeClient = (*RoutedClient)(nil) _ githubReviewerClient = (*RoutedClient)(nil) _ pageClient = (*RoutedClient)(nil) + _ githubWorkflowCatalogClient = (*RoutedClient)(nil) + _ githubWorkflowRunClient = (*RoutedClient)(nil) + _ githubWorkflowDispatchClient = (*RoutedClient)(nil) _ markdownImageClient = (*RoutedClient)(nil) _ repoUserClient = (*RoutedClient)(nil) _ NativeStackClient = (*RoutedClient)(nil) @@ -1059,3 +1062,75 @@ func (r *HostRouter) ClearDisplacedRepoCredentialAlias( } delete(r.repoAliases, key) } + +func (c *RoutedClient) ListRepositoryWorkflows(ctx context.Context, owner, repo string) ([]*gh.Workflow, error) { + client, err := c.routeForRepo(owner, repo) + if err != nil { + return nil, err + } + workflowClient, ok := client.(githubWorkflowCatalogClient) + if !ok { + return nil, platform.UnsupportedCapability(platform.KindGitHub, c.routes.host, "read_workflows") + } + return workflowClient.ListRepositoryWorkflows(ctx, owner, repo) +} + +func (c *RoutedClient) GetWorkflowDefinition(ctx context.Context, owner, repo, path, ref string) (string, string, error) { + client, err := c.routeForRepo(owner, repo) + if err != nil { + return "", "", err + } + workflowClient, ok := client.(githubWorkflowCatalogClient) + if !ok { + return "", "", platform.UnsupportedCapability(platform.KindGitHub, c.routes.host, "read_workflows") + } + return workflowClient.GetWorkflowDefinition(ctx, owner, repo, path, ref) +} + +func (c *RoutedClient) ListRepositoryEnvironments(ctx context.Context, owner, repo string) ([]*gh.Environment, error) { + client, err := c.routeForRepo(owner, repo) + if err != nil { + return nil, err + } + workflowClient, ok := client.(githubWorkflowCatalogClient) + if !ok { + return nil, platform.UnsupportedCapability(platform.KindGitHub, c.routes.host, "read_workflows") + } + return workflowClient.ListRepositoryEnvironments(ctx, owner, repo) +} + +func (c *RoutedClient) ListManualWorkflowRuns(ctx context.Context, owner, repo string, workflowID int64, query platform.WorkflowRunQuery) (platform.Page[*gh.WorkflowRun], error) { + client, err := c.routeForRepo(owner, repo) + if err != nil { + return platform.Page[*gh.WorkflowRun]{}, err + } + workflowClient, ok := client.(githubWorkflowRunClient) + if !ok { + return platform.Page[*gh.WorkflowRun]{}, platform.UnsupportedCapability(platform.KindGitHub, c.routes.host, "read_workflow_runs") + } + return workflowClient.ListManualWorkflowRuns(ctx, owner, repo, workflowID, query) +} + +func (c *RoutedClient) ListManualWorkflowJobs(ctx context.Context, owner, repo string, runID int64) ([]*gh.WorkflowJob, error) { + client, err := c.routeForRepo(owner, repo) + if err != nil { + return nil, err + } + workflowClient, ok := client.(githubWorkflowRunClient) + if !ok { + return nil, platform.UnsupportedCapability(platform.KindGitHub, c.routes.host, "read_workflow_runs") + } + return workflowClient.ListManualWorkflowJobs(ctx, owner, repo, runID) +} + +func (c *RoutedClient) DispatchManualWorkflow(ctx context.Context, owner, repo string, workflowID int64, request gh.CreateWorkflowDispatchEventRequest) (*gh.WorkflowDispatchRunDetails, error) { + client, err := c.routeForRepo(owner, repo) + if err != nil { + return nil, err + } + workflowClient, ok := client.(githubWorkflowDispatchClient) + if !ok { + return nil, platform.UnsupportedCapability(platform.KindGitHub, c.routes.host, "workflow_dispatch") + } + return workflowClient.DispatchManualWorkflow(ctx, owner, repo, workflowID, request) +} diff --git a/internal/github/client.go b/internal/github/client.go index eaabd3f4cf..7e15fdc398 100644 --- a/internal/github/client.go +++ b/internal/github/client.go @@ -20,6 +20,7 @@ import ( gh "github.com/google/go-github/v89/github" "go.kenn.io/forge/internal/platform" + platformgithub "go.kenn.io/forge/internal/platform/github" "go.kenn.io/forge/internal/tokenauth" ) @@ -3512,3 +3513,118 @@ func (c *liveClient) ListIssuesPage( hasMore := resp != nil && resp.NextPage > 0 return filtered, hasMore, nil } + +func (c *liveClient) ListRepositoryWorkflows( + ctx context.Context, owner, repo string, +) ([]*gh.Workflow, error) { + return collectPages(ctx, func(opts *gh.ListOptions) ([]*gh.Workflow, *gh.Response, error) { + workflows, resp, err := c.gh.Actions.ListWorkflows(ctx, owner, repo, opts) + if err != nil { + return nil, resp, err + } + return workflows.Workflows, resp, nil + }, c.trackRate) +} + +func (c *liveClient) GetWorkflowDefinition( + ctx context.Context, owner, repo, path, ref string, +) (string, string, error) { + file, directory, resp, err := c.gh.Repositories.GetContents( + ctx, owner, repo, path, &gh.RepositoryContentGetOptions{Ref: ref}, + ) + c.trackRate(resp) + if err != nil { + return "", "", err + } + if file == nil || directory != nil { + return "", "", fmt.Errorf("workflow definition %q is not a file", path) + } + content, err := file.GetContent() + if err != nil { + return "", "", fmt.Errorf("decode workflow definition %q: %w", path, err) + } + if len(content) > platformgithub.MaxWorkflowDefinitionBytes { + return "", "", fmt.Errorf( + "workflow definition exceeds %d-byte limit", + platformgithub.MaxWorkflowDefinitionBytes, + ) + } + return content, file.GetSHA(), nil +} + +func (c *liveClient) ListRepositoryEnvironments( + ctx context.Context, owner, repo string, +) ([]*gh.Environment, error) { + return collectPages(ctx, func(opts *gh.ListOptions) ([]*gh.Environment, *gh.Response, error) { + environments, resp, err := c.gh.Repositories.ListEnvironments( + ctx, owner, repo, &gh.EnvironmentListOptions{ListOptions: *opts}, + ) + if err != nil { + return nil, resp, err + } + return environments.Environments, resp, nil + }, c.trackRate) +} + +func (c *liveClient) ListManualWorkflowRuns( + ctx context.Context, + owner, repo string, + workflowID int64, + query platform.WorkflowRunQuery, +) (platform.Page[*gh.WorkflowRun], error) { + page := 0 + if query.Cursor != "" { + parsed, err := strconv.Atoi(query.Cursor) + if err != nil || parsed <= 0 { + return platform.Page[*gh.WorkflowRun]{}, &platform.Error{ + Code: platform.ErrCodeInvalidArgument, Provider: platform.KindGitHub, + PlatformHost: c.platformHost, Field: "cursor", + Err: fmt.Errorf("cursor must be a positive decimal GitHub page number"), + } + } + page = parsed + } + runs, resp, err := c.gh.Actions.ListWorkflowRunsByID( + ctx, owner, repo, workflowID, &gh.ListWorkflowRunsOptions{ + Event: query.Event, Branch: query.Branch, + ListOptions: gh.ListOptions{Page: page, PerPage: query.PerPage}, + }, + ) + c.trackRate(resp) + if err != nil { + return platform.Page[*gh.WorkflowRun]{}, err + } + result := platform.Page[*gh.WorkflowRun]{Items: runs.WorkflowRuns} + if resp != nil && resp.NextPage > 0 { + result.NextCursor = strconv.Itoa(resp.NextPage) + } + return result, nil +} + +func (c *liveClient) ListManualWorkflowJobs( + ctx context.Context, owner, repo string, runID int64, +) ([]*gh.WorkflowJob, error) { + return collectPages(ctx, func(opts *gh.ListOptions) ([]*gh.WorkflowJob, *gh.Response, error) { + jobs, resp, err := c.gh.Actions.ListWorkflowJobs( + ctx, owner, repo, runID, &gh.ListWorkflowJobsOptions{ListOptions: *opts}, + ) + if err != nil { + return nil, resp, err + } + return jobs.Jobs, resp, nil + }, c.trackRate) +} + +func (c *liveClient) DispatchManualWorkflow( + ctx context.Context, + owner, repo string, + workflowID int64, + request gh.CreateWorkflowDispatchEventRequest, +) (*gh.WorkflowDispatchRunDetails, error) { + request.ReturnRunDetails = gh.Ptr(true) + details, resp, err := c.writeGH().Actions.CreateWorkflowDispatchEventByID( + ctx, owner, repo, workflowID, request, + ) + c.trackWriteRate(resp) + return details, err +} diff --git a/internal/github/client_test.go b/internal/github/client_test.go index babb72a40f..af37b73282 100644 --- a/internal/github/client_test.go +++ b/internal/github/client_test.go @@ -2059,3 +2059,76 @@ func TestNewClientWiresETagTransport(t *testing.T) { _, ok = guard.base.(*etagTransport) require.Truef(ok, "expected *etagTransport under public GitHub guard, got %T", guard.base) } + +func TestWorkflowTransportShape(t *testing.T) { + var paths []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + paths = append(paths, r.Method+" "+r.URL.RequestURI()) + switch { + case strings.Contains(r.URL.Path, "/contents/"): + _ = json.NewEncoder(w).Encode(map[string]any{ + "type": "file", "sha": "blob-sha", + "content": base64.StdEncoding.EncodeToString([]byte("on: workflow_dispatch")), + "encoding": "base64", + }) + case strings.HasSuffix(r.URL.Path, "/environments"): + _ = json.NewEncoder(w).Encode(map[string]any{"environments": []any{map[string]any{"name": "prod"}}}) + case strings.HasSuffix(r.URL.Path, "/jobs"): + _ = json.NewEncoder(w).Encode(map[string]any{"jobs": []any{map[string]any{"id": 99}}}) + case strings.HasSuffix(r.URL.Path, "/runs"): + w.Header().Set("Link", `<`+serverURL(r)+`?page=3>; rel="next"`) + _ = json.NewEncoder(w).Encode(map[string]any{"workflow_runs": []any{map[string]any{"id": 7}}}) + case strings.HasSuffix(r.URL.Path, "/dispatches"): + var body map[string]any + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + assert.Equal(t, true, body["return_run_details"]) + assert.Equal(t, "main", body["ref"]) + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"workflow_run_id":123,"html_url":"https://example.test/runs/123"}`)) + default: + _ = json.NewEncoder(w).Encode(map[string]any{"workflows": []any{map[string]any{"id": 42}}}) + } + })) + defer server.Close() + ghClient, err := newEnterpriseGHClient(server.Client(), server.URL+"/", server.URL+"/") + require.NoError(t, err) + client := &liveClient{gh: ghClient, ghWrite: ghClient} + + workflows, err := client.ListRepositoryWorkflows(t.Context(), "acme", "widgets") + require.NoError(t, err) + require.Len(t, workflows, 1) + content, sha, err := client.GetWorkflowDefinition(t.Context(), "acme", "widgets", ".github/workflows/release.yml", "main") + require.NoError(t, err) + assert.Equal(t, "on: workflow_dispatch", content) + assert.Equal(t, "blob-sha", sha) + _, err = client.ListRepositoryEnvironments(t.Context(), "acme", "widgets") + require.NoError(t, err) + page, err := client.ListManualWorkflowRuns(t.Context(), "acme", "widgets", 42, platform.WorkflowRunQuery{ + Cursor: "2", PerPage: 25, Event: "workflow_dispatch", Branch: "main", + }) + require.NoError(t, err) + assert.Equal(t, "3", page.NextCursor) + _, err = client.ListManualWorkflowJobs(t.Context(), "acme", "widgets", 99) + require.NoError(t, err) + details, err := client.DispatchManualWorkflow(t.Context(), "acme", "widgets", 42, gh.CreateWorkflowDispatchEventRequest{ + Ref: "main", Inputs: map[string]any{"version": "1.2.3"}, + }) + require.NoError(t, err) + assert.Equal(t, int64(123), details.GetWorkflowRunID()) + + assert.Contains(t, paths, "GET /api/v3/repos/acme/widgets/actions/workflows?per_page=100") + assert.Contains(t, paths, "GET /api/v3/repos/acme/widgets/contents/.github/workflows/release.yml?ref=main") + assert.Contains(t, paths, "GET /api/v3/repos/acme/widgets/actions/workflows/42/runs?branch=main&event=workflow_dispatch&page=2&per_page=25") + assert.Contains(t, paths, "POST /api/v3/repos/acme/widgets/actions/workflows/42/dispatches") +} + +func serverURL(r *http.Request) string { + return "http://" + r.Host + r.URL.Path +} + +func TestListManualWorkflowRunsRejectsInvalidCursor(t *testing.T) { + client := &liveClient{platformHost: "github.com"} + _, err := client.ListManualWorkflowRuns(t.Context(), "acme", "widgets", 42, platform.WorkflowRunQuery{Cursor: "zero"}) + require.ErrorIs(t, err, platform.ErrInvalidArgument) +} + diff --git a/internal/github/sync.go b/internal/github/sync.go index 130848b1fc..0c345f18ff 100644 --- a/internal/github/sync.go +++ b/internal/github/sync.go @@ -1951,6 +1951,21 @@ type githubReviewerClient interface { RemovePullRequestReviewers(ctx context.Context, owner, repo string, number int, usernames []string) error } +type githubWorkflowCatalogClient interface { + ListRepositoryWorkflows(context.Context, string, string) ([]*gh.Workflow, error) + GetWorkflowDefinition(context.Context, string, string, string, string) (string, string, error) + ListRepositoryEnvironments(context.Context, string, string) ([]*gh.Environment, error) +} + +type githubWorkflowRunClient interface { + ListManualWorkflowRuns(context.Context, string, string, int64, platform.WorkflowRunQuery) (platform.Page[*gh.WorkflowRun], error) + ListManualWorkflowJobs(context.Context, string, string, int64) ([]*gh.WorkflowJob, error) +} + +type githubWorkflowDispatchClient interface { + DispatchManualWorkflow(context.Context, string, string, int64, gh.CreateWorkflowDispatchEventRequest) (*gh.WorkflowDispatchRunDetails, error) +} + func registryFromGitHubClients(clients map[string]Client) *platform.Registry { registry, err := platform.NewRegistry() if err != nil { @@ -1994,6 +2009,9 @@ func (p *gitHubClientProvider) Capabilities() platform.Capabilities { _, labels := p.client.(githubLabelClient) _, assignees := p.client.(githubAssigneeClient) _, reviewers := p.client.(githubReviewerClient) + _, workflows := p.client.(githubWorkflowCatalogClient) + _, workflowRuns := p.client.(githubWorkflowRunClient) + _, workflowDispatch := p.client.(githubWorkflowDispatchClient) _, archivePages := p.client.(pageClient) _, markdownImages := p.client.(markdownImageClient) _, directViewer := p.client.(authenticatedViewerLoginClient) @@ -2012,12 +2030,15 @@ func (p *gitHubClientProvider) Capabilities() platform.Capabilities { ReadMarkdownImages: markdownImages, ReadAuthenticatedUser: directViewer || routedViewer, ReadNotifications: true, + ReadWorkflows: workflows, + ReadWorkflowRuns: workflowRuns, CommentMutation: true, StateMutation: true, MergeMutation: true, ReviewMutation: true, MutationHeadBinding: true, WorkflowApproval: true, + WorkflowDispatch: workflowDispatch, ReadyForReview: true, DraftMutation: true, IssueMutation: true, @@ -2210,6 +2231,218 @@ func (p *gitHubClientProvider) GetRepository( return gitHubPlatformRepository(p.host, ref.Owner, repo), nil } +func (p *gitHubClientProvider) ListManualWorkflows( + ctx context.Context, ref platform.RepoRef, +) ([]platform.WorkflowDefinition, error) { + client, ok := p.client.(githubWorkflowCatalogClient) + if !ok { + return nil, platform.UnsupportedCapability(platform.KindGitHub, p.host, "read_workflows") + } + repo, err := p.client.GetRepository(ctx, ref.Owner, ref.Name) + if err != nil { + return nil, err + } + workflows, err := client.ListRepositoryWorkflows(ctx, ref.Owner, ref.Name) + if err != nil { + return nil, err + } + definitions := make([]platform.WorkflowDefinition, 0, len(workflows)) + for _, workflow := range workflows { + if workflow == nil || workflow.GetState() != "active" { + continue + } + content, sha, fileErr := client.GetWorkflowDefinition( + ctx, ref.Owner, ref.Name, workflow.GetPath(), repo.GetDefaultBranch(), + ) + if fileErr != nil { + definitions = append(definitions, platform.WorkflowDefinition{ + ID: strconv.FormatInt(workflow.GetID(), 10), Name: workflow.GetName(), + Path: workflow.GetPath(), State: workflow.GetState(), WebURL: workflow.GetHTMLURL(), + Available: false, UnavailableReason: fileErr.Error(), + }) + continue + } + definition, manual, parseErr := platformgithub.ParseManualWorkflow( + workflow.GetName(), workflow.GetPath(), workflow.GetHTMLURL(), sha, []byte(content), + ) + definition.ID = strconv.FormatInt(workflow.GetID(), 10) + definition.State = workflow.GetState() + if parseErr != nil { + definition.Available = false + definition.UnavailableReason = parseErr.Error() + definitions = append(definitions, definition) + continue + } + if manual { + definitions = append(definitions, definition) + } + } + return definitions, nil +} + +func (p *gitHubClientProvider) ListWorkflowEnvironments( + ctx context.Context, ref platform.RepoRef, +) ([]platform.WorkflowEnvironment, error) { + client, ok := p.client.(githubWorkflowCatalogClient) + if !ok { + return nil, platform.UnsupportedCapability(platform.KindGitHub, p.host, "read_workflows") + } + workflows, err := p.ListManualWorkflows(ctx, ref) + if err != nil { + return nil, err + } + needed := false + for _, workflow := range workflows { + if !workflow.Available { + continue + } + for _, input := range workflow.Inputs { + if input.Type == platform.WorkflowInputEnvironment { + needed = true + break + } + } + } + if !needed { + return []platform.WorkflowEnvironment{}, nil + } + environments, err := client.ListRepositoryEnvironments(ctx, ref.Owner, ref.Name) + if err != nil { + return nil, err + } + result := make([]platform.WorkflowEnvironment, 0, len(environments)) + for _, environment := range environments { + if environment != nil { + result = append(result, platform.WorkflowEnvironment{Name: environment.GetName()}) + } + } + return result, nil +} + +func parseGitHubWorkflowID(host, field, raw string) (int64, error) { + id, err := strconv.ParseInt(raw, 10, 64) + if err != nil || id <= 0 { + return 0, &platform.Error{ + Code: platform.ErrCodeInvalidArgument, Provider: platform.KindGitHub, + PlatformHost: host, Field: field, Err: fmt.Errorf("%s must be a positive decimal GitHub ID", field), + } + } + return id, nil +} + +func (p *gitHubClientProvider) ListWorkflowRuns( + ctx context.Context, ref platform.RepoRef, query platform.WorkflowRunQuery, +) (platform.Page[platform.WorkflowRun], error) { + client, ok := p.client.(githubWorkflowRunClient) + if !ok { + return platform.Page[platform.WorkflowRun]{}, platform.UnsupportedCapability(platform.KindGitHub, p.host, "read_workflow_runs") + } + workflowID, err := parseGitHubWorkflowID(p.host, "workflow_id", query.WorkflowID) + if err != nil { + return platform.Page[platform.WorkflowRun]{}, err + } + page, err := client.ListManualWorkflowRuns(ctx, ref.Owner, ref.Name, workflowID, query) + if err != nil { + return platform.Page[platform.WorkflowRun]{}, err + } + result := platform.Page[platform.WorkflowRun]{NextCursor: page.NextCursor, Items: make([]platform.WorkflowRun, 0, len(page.Items))} + for _, run := range page.Items { + result.Items = append(result.Items, normalizeGitHubWorkflowRun(run)) + } + return result, nil +} + +func (p *gitHubClientProvider) ListWorkflowRunJobs( + ctx context.Context, ref platform.RepoRef, rawRunID string, +) ([]platform.WorkflowRunJob, error) { + client, ok := p.client.(githubWorkflowRunClient) + if !ok { + return nil, platform.UnsupportedCapability(platform.KindGitHub, p.host, "read_workflow_runs") + } + runID, err := parseGitHubWorkflowID(p.host, "run_id", rawRunID) + if err != nil { + return nil, err + } + jobs, err := client.ListManualWorkflowJobs(ctx, ref.Owner, ref.Name, runID) + if err != nil { + return nil, err + } + result := make([]platform.WorkflowRunJob, 0, len(jobs)) + for _, job := range jobs { + if job == nil { + continue + } + normalized := platform.WorkflowRunJob{ + ID: strconv.FormatInt(job.GetID(), 10), Name: job.GetName(), + Status: job.GetStatus(), Conclusion: job.GetConclusion(), + StartedAt: githubWorkflowTimestamp(job.StartedAt), + CompletedAt: githubWorkflowTimestamp(job.CompletedAt), WebURL: job.GetHTMLURL(), + Steps: make([]platform.WorkflowRunStep, 0, len(job.Steps)), + } + for _, step := range job.Steps { + if step == nil { + continue + } + normalized.Steps = append(normalized.Steps, platform.WorkflowRunStep{ + Number: int(step.GetNumber()), Name: step.GetName(), Status: step.GetStatus(), + Conclusion: step.GetConclusion(), StartedAt: githubWorkflowTimestamp(step.StartedAt), + CompletedAt: githubWorkflowTimestamp(step.CompletedAt), + }) + } + result = append(result, normalized) + } + return result, nil +} + +func (p *gitHubClientProvider) DispatchWorkflow( + ctx context.Context, ref platform.RepoRef, request platform.WorkflowDispatchRequest, +) (platform.WorkflowDispatchResult, error) { + client, ok := p.client.(githubWorkflowDispatchClient) + if !ok { + return platform.WorkflowDispatchResult{}, platform.UnsupportedCapability(platform.KindGitHub, p.host, "workflow_dispatch") + } + workflowID, err := parseGitHubWorkflowID(p.host, "workflow_id", request.WorkflowID) + if err != nil { + return platform.WorkflowDispatchResult{}, err + } + details, err := client.DispatchManualWorkflow(ctx, ref.Owner, ref.Name, workflowID, gh.CreateWorkflowDispatchEventRequest{ + Ref: request.Ref, Inputs: request.Inputs, + }) + if err != nil { + return platform.WorkflowDispatchResult{}, err + } + result := platform.WorkflowDispatchResult{Accepted: true, LocatingRun: details == nil || details.GetWorkflowRunID() == 0} + if !result.LocatingRun { + run := platform.WorkflowRun{ + ID: strconv.FormatInt(details.GetWorkflowRunID(), 10), + WorkflowID: request.WorkflowID, WebURL: details.GetHTMLURL(), + } + result.Run = &run + } + return result, nil +} + +func normalizeGitHubWorkflowRun(run *gh.WorkflowRun) platform.WorkflowRun { + if run == nil { + return platform.WorkflowRun{} + } + return platform.WorkflowRun{ + ID: strconv.FormatInt(run.GetID(), 10), WorkflowID: strconv.FormatInt(run.GetWorkflowID(), 10), + RunNumber: int64(run.GetRunNumber()), Name: run.GetName(), Event: run.GetEvent(), + Ref: run.GetHeadBranch(), HeadSHA: run.GetHeadSHA(), Actor: run.GetActor().GetLogin(), + Status: run.GetStatus(), Conclusion: run.GetConclusion(), + CreatedAt: githubWorkflowTimestamp(run.CreatedAt), UpdatedAt: githubWorkflowTimestamp(run.UpdatedAt), + WebURL: run.GetHTMLURL(), + } +} + +func githubWorkflowTimestamp(timestamp *gh.Timestamp) time.Time { + if timestamp == nil { + return time.Time{} + } + return timestamp.Time.UTC() +} + // gitHubPlatformRepository converts a GitHub REST repository into the // provider-neutral snapshot, preferring the canonical owner the provider // reports over the requested route owner. diff --git a/internal/github/workflow_provider_test.go b/internal/github/workflow_provider_test.go new file mode 100644 index 0000000000..3ff75b4c75 --- /dev/null +++ b/internal/github/workflow_provider_test.go @@ -0,0 +1,139 @@ +package github + +import ( + "context" + "errors" + "testing" + "time" + + gh "github.com/google/go-github/v89/github" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "go.kenn.io/forge/internal/platform" +) + +type workflowProviderFake struct { + Client + workflows []*gh.Workflow + definitions map[string]string + environments []*gh.Environment + runs platform.Page[*gh.WorkflowRun] + jobs []*gh.WorkflowJob + dispatch *gh.WorkflowDispatchRunDetails + definitionRefs []string +} + +func (f *workflowProviderFake) GetRepository(context.Context, string, string) (*gh.Repository, error) { + return &gh.Repository{Name: gh.Ptr("widgets"), DefaultBranch: gh.Ptr("trunk")}, nil +} +func (f *workflowProviderFake) ListRepositoryWorkflows(context.Context, string, string) ([]*gh.Workflow, error) { + return f.workflows, nil +} +func (f *workflowProviderFake) GetWorkflowDefinition(_ context.Context, _, _, path, ref string) (string, string, error) { + f.definitionRefs = append(f.definitionRefs, ref) + content, ok := f.definitions[path] + if !ok { return "", "", errors.New("definition unavailable") } + return content, "sha-" + path, nil +} +func (f *workflowProviderFake) ListRepositoryEnvironments(context.Context, string, string) ([]*gh.Environment, error) { + return f.environments, nil +} +func (f *workflowProviderFake) ListManualWorkflowRuns(context.Context, string, string, int64, platform.WorkflowRunQuery) (platform.Page[*gh.WorkflowRun], error) { + return f.runs, nil +} +func (f *workflowProviderFake) ListManualWorkflowJobs(context.Context, string, string, int64) ([]*gh.WorkflowJob, error) { + return f.jobs, nil +} +func (f *workflowProviderFake) DispatchManualWorkflow(context.Context, string, string, int64, gh.CreateWorkflowDispatchEventRequest) (*gh.WorkflowDispatchRunDetails, error) { + return f.dispatch, nil +} + +func TestGitHubWorkflowProviderCatalogPreservesPartialAvailability(t *testing.T) { + fake := &workflowProviderFake{ + workflows: []*gh.Workflow{ + {ID: gh.Ptr(int64(1)), Name: gh.Ptr("Release"), Path: gh.Ptr(".github/workflows/release.yml"), State: gh.Ptr("active"), HTMLURL: gh.Ptr("https://example.test/release")}, + {ID: gh.Ptr(int64(2)), Name: gh.Ptr("Broken"), Path: gh.Ptr(".github/workflows/broken.yml"), State: gh.Ptr("active")}, + {ID: gh.Ptr(int64(3)), Name: gh.Ptr("CI"), Path: gh.Ptr(".github/workflows/ci.yml"), State: gh.Ptr("active")}, + {ID: gh.Ptr(int64(4)), Name: gh.Ptr("Disabled"), Path: gh.Ptr("disabled.yml"), State: gh.Ptr("disabled_manually")}, + }, + definitions: map[string]string{ + ".github/workflows/release.yml": "on:\n workflow_dispatch:\n inputs:\n target:\n type: environment\n", + ".github/workflows/ci.yml": "on: [push]\n", + }, + environments: []*gh.Environment{{Name: gh.Ptr("production")}}, + } + provider := &gitHubClientProvider{host: "github.com", client: fake} + caps := provider.Capabilities() + assert.True(t, caps.ReadWorkflows) + assert.True(t, caps.ReadWorkflowRuns) + assert.True(t, caps.WorkflowDispatch) + + catalog, err := provider.ListManualWorkflows(t.Context(), platform.RepoRef{Owner: "acme", Name: "widgets"}) + require.NoError(t, err) + require.Len(t, catalog, 2) + assert.Equal(t, "1", catalog[0].ID) + assert.True(t, catalog[0].Available) + assert.Equal(t, "2", catalog[1].ID) + assert.False(t, catalog[1].Available) + assert.NotEmpty(t, catalog[1].UnavailableReason) + assert.Equal(t, []string{"trunk", "trunk", "trunk"}, fake.definitionRefs) + + environments, err := provider.ListWorkflowEnvironments(t.Context(), platform.RepoRef{Owner: "acme", Name: "widgets"}) + require.NoError(t, err) + assert.Equal(t, []platform.WorkflowEnvironment{{Name: "production"}}, environments) +} + +func TestGitHubWorkflowProviderNormalizesRunsJobsAndDispatch(t *testing.T) { + created := time.Date(2026, 8, 27, 12, 0, 0, 0, time.FixedZone("offset", 3600)) + updated := created.Add(time.Minute) + started := created.Add(time.Second) + completed := created.Add(2 * time.Second) + fake := &workflowProviderFake{ + runs: platform.Page[*gh.WorkflowRun]{NextCursor: "3", Items: []*gh.WorkflowRun{{ + ID: gh.Ptr(int64(100)), WorkflowID: gh.Ptr(int64(42)), RunNumber: gh.Ptr(7), Name: gh.Ptr("Release"), + Event: gh.Ptr("workflow_dispatch"), HeadBranch: gh.Ptr("main"), HeadSHA: gh.Ptr("abc"), Actor: &gh.User{Login: gh.Ptr("octocat")}, + Status: gh.Ptr("completed"), Conclusion: gh.Ptr("success"), CreatedAt: &gh.Timestamp{Time: created}, UpdatedAt: &gh.Timestamp{Time: updated}, HTMLURL: gh.Ptr("https://example.test/runs/100"), + }}}, + jobs: []*gh.WorkflowJob{{ID: gh.Ptr(int64(9)), Name: gh.Ptr("deploy"), Status: gh.Ptr("completed"), Conclusion: gh.Ptr("success"), StartedAt: &gh.Timestamp{Time: started}, CompletedAt: &gh.Timestamp{Time: completed}, HTMLURL: gh.Ptr("https://example.test/jobs/9"), Steps: []*gh.TaskStep{{Number: gh.Ptr(int64(1)), Name: gh.Ptr("ship"), Status: gh.Ptr("completed"), Conclusion: gh.Ptr("success"), StartedAt: &gh.Timestamp{Time: started}, CompletedAt: &gh.Timestamp{Time: completed}}}}}, + dispatch: &gh.WorkflowDispatchRunDetails{WorkflowRunID: gh.Ptr(int64(101)), HTMLURL: gh.Ptr("https://example.test/runs/101")}, + } + provider := &gitHubClientProvider{host: "github.com", client: fake} + page, err := provider.ListWorkflowRuns(t.Context(), platform.RepoRef{Owner: "acme", Name: "widgets"}, platform.WorkflowRunQuery{WorkflowID: "42"}) + require.NoError(t, err) + require.Len(t, page.Items, 1) + assert.Equal(t, "100", page.Items[0].ID) + assert.Equal(t, "octocat", page.Items[0].Actor) + assert.Equal(t, created.UTC(), page.Items[0].CreatedAt) + assert.Equal(t, "3", page.NextCursor) + jobs, err := provider.ListWorkflowRunJobs(t.Context(), platform.RepoRef{Owner: "acme", Name: "widgets"}, "100") + require.NoError(t, err) + require.Len(t, jobs, 1) + assert.Equal(t, "9", jobs[0].ID) + require.Len(t, jobs[0].Steps, 1) + assert.Equal(t, 1, jobs[0].Steps[0].Number) + result, err := provider.DispatchWorkflow(t.Context(), platform.RepoRef{Owner: "acme", Name: "widgets"}, platform.WorkflowDispatchRequest{WorkflowID: "42", Ref: "main"}) + require.NoError(t, err) + assert.True(t, result.Accepted) + assert.False(t, result.LocatingRun) + require.NotNil(t, result.Run) + assert.Equal(t, "101", result.Run.ID) + + fake.dispatch = nil + result, err = provider.DispatchWorkflow(t.Context(), platform.RepoRef{Owner: "acme", Name: "widgets"}, platform.WorkflowDispatchRequest{WorkflowID: "42", Ref: "main"}) + require.NoError(t, err) + assert.True(t, result.LocatingRun) + _, err = provider.DispatchWorkflow(t.Context(), platform.RepoRef{}, platform.WorkflowDispatchRequest{WorkflowID: "not-decimal"}) + require.ErrorIs(t, err, platform.ErrInvalidArgument) +} + +func TestGitHubWorkflowProviderUnsupportedClientsAreTyped(t *testing.T) { + provider := &gitHubClientProvider{host: "github.com", client: &mockClient{}} + assert.False(t, provider.Capabilities().ReadWorkflows) + _, err := provider.ListManualWorkflows(t.Context(), platform.RepoRef{}) + require.ErrorIs(t, err, platform.ErrUnsupportedCapability) + _, err = provider.ListWorkflowRuns(t.Context(), platform.RepoRef{}, platform.WorkflowRunQuery{}) + require.ErrorIs(t, err, platform.ErrUnsupportedCapability) + _, err = provider.DispatchWorkflow(t.Context(), platform.RepoRef{}, platform.WorkflowDispatchRequest{}) + require.ErrorIs(t, err, platform.ErrUnsupportedCapability) +} From 2a8a076700c468a28ba461c5f26711596b4f6003 Mon Sep 17 00:00:00 2001 From: Max Mill Date: Fri, 28 Aug 2026 16:10:25 +0200 Subject: [PATCH 06/41] test: cover routed GitHub workflow clients --- internal/github/workflow_provider_test.go | 39 +++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/internal/github/workflow_provider_test.go b/internal/github/workflow_provider_test.go index 3ff75b4c75..d7cc354142 100644 --- a/internal/github/workflow_provider_test.go +++ b/internal/github/workflow_provider_test.go @@ -22,30 +22,37 @@ type workflowProviderFake struct { jobs []*gh.WorkflowJob dispatch *gh.WorkflowDispatchRunDetails definitionRefs []string + calls []string } func (f *workflowProviderFake) GetRepository(context.Context, string, string) (*gh.Repository, error) { return &gh.Repository{Name: gh.Ptr("widgets"), DefaultBranch: gh.Ptr("trunk")}, nil } func (f *workflowProviderFake) ListRepositoryWorkflows(context.Context, string, string) ([]*gh.Workflow, error) { + f.calls = append(f.calls, "workflows") return f.workflows, nil } func (f *workflowProviderFake) GetWorkflowDefinition(_ context.Context, _, _, path, ref string) (string, string, error) { + f.calls = append(f.calls, "definition:"+path+"@"+ref) f.definitionRefs = append(f.definitionRefs, ref) content, ok := f.definitions[path] if !ok { return "", "", errors.New("definition unavailable") } return content, "sha-" + path, nil } func (f *workflowProviderFake) ListRepositoryEnvironments(context.Context, string, string) ([]*gh.Environment, error) { + f.calls = append(f.calls, "environments") return f.environments, nil } func (f *workflowProviderFake) ListManualWorkflowRuns(context.Context, string, string, int64, platform.WorkflowRunQuery) (platform.Page[*gh.WorkflowRun], error) { + f.calls = append(f.calls, "runs") return f.runs, nil } func (f *workflowProviderFake) ListManualWorkflowJobs(context.Context, string, string, int64) ([]*gh.WorkflowJob, error) { + f.calls = append(f.calls, "jobs") return f.jobs, nil } func (f *workflowProviderFake) DispatchManualWorkflow(context.Context, string, string, int64, gh.CreateWorkflowDispatchEventRequest) (*gh.WorkflowDispatchRunDetails, error) { + f.calls = append(f.calls, "dispatch") return f.dispatch, nil } @@ -137,3 +144,35 @@ func TestGitHubWorkflowProviderUnsupportedClientsAreTyped(t *testing.T) { _, err = provider.DispatchWorkflow(t.Context(), platform.RepoRef{}, platform.WorkflowDispatchRequest{}) require.ErrorIs(t, err, platform.ErrUnsupportedCapability) } + +func TestRoutedClientRoutesWorkflowOperationsByRepository(t *testing.T) { + fallback := &workflowProviderFake{} + exact := &workflowProviderFake{ + definitions: map[string]string{"release.yml": "on: workflow_dispatch"}, + dispatch: &gh.WorkflowDispatchRunDetails{WorkflowRunID: gh.Ptr(int64(8))}, + } + router, err := NewHostRouter( + "github.com", + &Route{Key: RouteKey{Host: "github.com"}, Client: fallback}, + &Route{Key: RouteKey{Host: "github.com", Owner: "acme", Name: "widgets"}, Client: exact}, + ) + require.NoError(t, err) + routed, err := NewRoutedClient(router) + require.NoError(t, err) + _, err = routed.ListRepositoryWorkflows(t.Context(), "acme", "widgets") + require.NoError(t, err) + _, _, err = routed.GetWorkflowDefinition(t.Context(), "acme", "widgets", "release.yml", "main") + require.NoError(t, err) + _, err = routed.ListRepositoryEnvironments(t.Context(), "acme", "widgets") + require.NoError(t, err) + _, err = routed.ListManualWorkflowRuns(t.Context(), "acme", "widgets", 42, platform.WorkflowRunQuery{}) + require.NoError(t, err) + _, err = routed.ListManualWorkflowJobs(t.Context(), "acme", "widgets", 99) + require.NoError(t, err) + _, err = routed.DispatchManualWorkflow(t.Context(), "acme", "widgets", 42, gh.CreateWorkflowDispatchEventRequest{Ref: "main"}) + require.NoError(t, err) + assert.Equal(t, []string{ + "workflows", "definition:release.yml@main", "environments", "runs", "jobs", "dispatch", + }, exact.calls) + assert.Empty(t, fallback.calls) +} From 17ac6a2203ecb05399ff68fa308eab974cc3bcba Mon Sep 17 00:00:00 2001 From: Max Mill Date: Fri, 28 Aug 2026 16:25:01 +0200 Subject: [PATCH 07/41] test: harden GitHub workflow provider coverage --- internal/github/client_test.go | 119 +++++++++++++++---- internal/github/workflow_provider_test.go | 138 +++++++++++++++++++--- 2 files changed, 215 insertions(+), 42 deletions(-) diff --git a/internal/github/client_test.go b/internal/github/client_test.go index af37b73282..ea745b9329 100644 --- a/internal/github/client_test.go +++ b/internal/github/client_test.go @@ -1,6 +1,7 @@ package github import ( + "bytes" "context" "encoding/base64" "encoding/json" @@ -20,6 +21,7 @@ import ( "github.com/stretchr/testify/require" "go.kenn.io/forge/internal/platform" + platformgithub "go.kenn.io/forge/internal/platform/github" "go.kenn.io/forge/internal/tokenauth" ) @@ -2061,9 +2063,11 @@ func TestNewClientWiresETagTransport(t *testing.T) { } func TestWorkflowTransportShape(t *testing.T) { - var paths []string - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - paths = append(paths, r.Method+" "+r.URL.RequestURI()) + var readPaths, writePaths []string + readServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + readPaths = append(readPaths, r.Method+" "+r.URL.RequestURI()) + w.Header().Set("X-RateLimit-Limit", "5000") + w.Header().Set("X-RateLimit-Remaining", "4900") switch { case strings.Contains(r.URL.Path, "/contents/"): _ = json.NewEncoder(w).Encode(map[string]any{ @@ -2072,54 +2076,107 @@ func TestWorkflowTransportShape(t *testing.T) { "encoding": "base64", }) case strings.HasSuffix(r.URL.Path, "/environments"): - _ = json.NewEncoder(w).Encode(map[string]any{"environments": []any{map[string]any{"name": "prod"}}}) + if r.URL.Query().Get("page") == "" { + w.Header().Set("Link", `<`+serverURL(r)+`?page=2>; rel="next"`) + } + _ = json.NewEncoder(w).Encode(map[string]any{"environments": []any{map[string]any{"name": "env-" + max(r.URL.Query().Get("page"), "1")}}}) case strings.HasSuffix(r.URL.Path, "/jobs"): + if r.URL.Query().Get("page") == "" { + w.Header().Set("Link", `<`+serverURL(r)+`?page=2>; rel="next"`) + } _ = json.NewEncoder(w).Encode(map[string]any{"jobs": []any{map[string]any{"id": 99}}}) case strings.HasSuffix(r.URL.Path, "/runs"): - w.Header().Set("Link", `<`+serverURL(r)+`?page=3>; rel="next"`) + if r.URL.Query().Get("page") == "2" { + w.Header().Set("Link", `<`+serverURL(r)+`?page=3>; rel="next"`) + } _ = json.NewEncoder(w).Encode(map[string]any{"workflow_runs": []any{map[string]any{"id": 7}}}) - case strings.HasSuffix(r.URL.Path, "/dispatches"): - var body map[string]any - require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) - assert.Equal(t, true, body["return_run_details"]) - assert.Equal(t, "main", body["ref"]) - w.WriteHeader(http.StatusCreated) - _, _ = w.Write([]byte(`{"workflow_run_id":123,"html_url":"https://example.test/runs/123"}`)) default: + if r.URL.Query().Get("page") == "" { + w.Header().Set("Link", `<`+serverURL(r)+`?page=2>; rel="next"`) + } _ = json.NewEncoder(w).Encode(map[string]any{"workflows": []any{map[string]any{"id": 42}}}) } })) - defer server.Close() - ghClient, err := newEnterpriseGHClient(server.Client(), server.URL+"/", server.URL+"/") + defer readServer.Close() + dispatchCount := 0 + var dispatchBodies []map[string]any + writeServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + writePaths = append(writePaths, r.Method+" "+r.URL.RequestURI()) + w.Header().Set("X-RateLimit-Limit", "5000") + w.Header().Set("X-RateLimit-Remaining", "4800") + var body map[string]any + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + dispatchBodies = append(dispatchBodies, body) + dispatchCount++ + if dispatchCount == 1 { + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"workflow_run_id":123,"html_url":"https://example.test/runs/123"}`)) + return + } + w.WriteHeader(http.StatusNoContent) + })) + defer writeServer.Close() + readGH, err := newEnterpriseGHClient(readServer.Client(), readServer.URL+"/", readServer.URL+"/") require.NoError(t, err) - client := &liveClient{gh: ghClient, ghWrite: ghClient} + writeGH, err := newEnterpriseGHClient(writeServer.Client(), writeServer.URL+"/", writeServer.URL+"/") + require.NoError(t, err) + database := openTestDB(t) + readTracker := NewRateTracker(database, "github.example.com", "installation:1", "rest") + writeTracker := NewRateTracker(database, "github.example.com", "user:2", "rest") + client := &liveClient{ + gh: readGH, ghWrite: writeGH, rateTracker: readTracker, writeRateTracker: writeTracker, + } workflows, err := client.ListRepositoryWorkflows(t.Context(), "acme", "widgets") require.NoError(t, err) - require.Len(t, workflows, 1) + require.Len(t, workflows, 2) content, sha, err := client.GetWorkflowDefinition(t.Context(), "acme", "widgets", ".github/workflows/release.yml", "main") require.NoError(t, err) assert.Equal(t, "on: workflow_dispatch", content) assert.Equal(t, "blob-sha", sha) - _, err = client.ListRepositoryEnvironments(t.Context(), "acme", "widgets") + environments, err := client.ListRepositoryEnvironments(t.Context(), "acme", "widgets") require.NoError(t, err) + require.Len(t, environments, 2) page, err := client.ListManualWorkflowRuns(t.Context(), "acme", "widgets", 42, platform.WorkflowRunQuery{ Cursor: "2", PerPage: 25, Event: "workflow_dispatch", Branch: "main", }) require.NoError(t, err) assert.Equal(t, "3", page.NextCursor) - _, err = client.ListManualWorkflowJobs(t.Context(), "acme", "widgets", 99) + _, err = client.ListManualWorkflowRuns(t.Context(), "acme", "widgets", 42, platform.WorkflowRunQuery{PerPage: 10}) require.NoError(t, err) + jobs, err := client.ListManualWorkflowJobs(t.Context(), "acme", "widgets", 99) + require.NoError(t, err) + require.Len(t, jobs, 2) details, err := client.DispatchManualWorkflow(t.Context(), "acme", "widgets", 42, gh.CreateWorkflowDispatchEventRequest{ Ref: "main", Inputs: map[string]any{"version": "1.2.3"}, }) require.NoError(t, err) assert.Equal(t, int64(123), details.GetWorkflowRunID()) - - assert.Contains(t, paths, "GET /api/v3/repos/acme/widgets/actions/workflows?per_page=100") - assert.Contains(t, paths, "GET /api/v3/repos/acme/widgets/contents/.github/workflows/release.yml?ref=main") - assert.Contains(t, paths, "GET /api/v3/repos/acme/widgets/actions/workflows/42/runs?branch=main&event=workflow_dispatch&page=2&per_page=25") - assert.Contains(t, paths, "POST /api/v3/repos/acme/widgets/actions/workflows/42/dispatches") + details, err = client.DispatchManualWorkflow(t.Context(), "acme", "widgets", 42, gh.CreateWorkflowDispatchEventRequest{Ref: "main"}) + require.NoError(t, err) + assert.Nil(t, details) + + assert.Equal(t, []map[string]any{ + {"ref": "main", "inputs": map[string]any{"version": "1.2.3"}, "return_run_details": true}, + {"ref": "main", "return_run_details": true}, + }, dispatchBodies) + assert.Contains(t, readPaths, "GET /api/v3/repos/acme/widgets/actions/workflows?per_page=100") + assert.Contains(t, readPaths, "GET /api/v3/repos/acme/widgets/actions/workflows?page=2&per_page=100") + assert.Contains(t, readPaths, "GET /api/v3/repos/acme/widgets/contents/.github/workflows/release.yml?ref=main") + assert.Contains(t, readPaths, "GET /api/v3/repos/acme/widgets/environments?per_page=100") + assert.Contains(t, readPaths, "GET /api/v3/repos/acme/widgets/environments?page=2&per_page=100") + assert.Contains(t, readPaths, "GET /api/v3/repos/acme/widgets/actions/workflows/42/runs?branch=main&event=workflow_dispatch&page=2&per_page=25") + assert.Contains(t, readPaths, "GET /api/v3/repos/acme/widgets/actions/workflows/42/runs?per_page=10") + assert.Contains(t, readPaths, "GET /api/v3/repos/acme/widgets/actions/runs/99/jobs?per_page=100") + assert.Contains(t, readPaths, "GET /api/v3/repos/acme/widgets/actions/runs/99/jobs?page=2&per_page=100") + assert.Equal(t, []string{ + "POST /api/v3/repos/acme/widgets/actions/workflows/42/dispatches", + "POST /api/v3/repos/acme/widgets/actions/workflows/42/dispatches", + }, writePaths) + assert.Equal(t, 9, readTracker.RequestsThisHour()) + assert.Equal(t, 4900, readTracker.Remaining()) + assert.Equal(t, 2, writeTracker.RequestsThisHour()) + assert.Equal(t, 4800, writeTracker.Remaining()) } func serverURL(r *http.Request) string { @@ -2132,3 +2189,19 @@ func TestListManualWorkflowRunsRejectsInvalidCursor(t *testing.T) { require.ErrorIs(t, err, platform.ErrInvalidArgument) } +func TestGetWorkflowDefinitionRejectsOversizedDecodedContent(t *testing.T) { + oversized := bytes.Repeat([]byte("x"), platformgithub.MaxWorkflowDefinitionBytes+1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{ + "type": "file", "sha": "too-large", "encoding": "base64", + "content": base64.StdEncoding.EncodeToString(oversized), + }) + })) + defer server.Close() + ghClient, err := newEnterpriseGHClient(server.Client(), server.URL+"/", server.URL+"/") + require.NoError(t, err) + client := &liveClient{gh: ghClient} + _, _, err = client.GetWorkflowDefinition(t.Context(), "acme", "widgets", "release.yml", "main") + require.ErrorContains(t, err, "exceeds") +} + diff --git a/internal/github/workflow_provider_test.go b/internal/github/workflow_provider_test.go index d7cc354142..45013e0fe8 100644 --- a/internal/github/workflow_provider_test.go +++ b/internal/github/workflow_provider_test.go @@ -56,17 +56,85 @@ func (f *workflowProviderFake) DispatchManualWorkflow(context.Context, string, s return f.dispatch, nil } +type workflowCatalogOnlyFake struct{ Client } + +func (*workflowCatalogOnlyFake) ListRepositoryWorkflows(context.Context, string, string) ([]*gh.Workflow, error) { + return nil, nil +} +func (*workflowCatalogOnlyFake) GetWorkflowDefinition(context.Context, string, string, string, string) (string, string, error) { + return "", "", nil +} +func (*workflowCatalogOnlyFake) ListRepositoryEnvironments(context.Context, string, string) ([]*gh.Environment, error) { + return nil, nil +} + +type workflowRunOnlyFake struct{ Client } + +func (*workflowRunOnlyFake) ListManualWorkflowRuns(context.Context, string, string, int64, platform.WorkflowRunQuery) (platform.Page[*gh.WorkflowRun], error) { + return platform.Page[*gh.WorkflowRun]{}, nil +} +func (*workflowRunOnlyFake) ListManualWorkflowJobs(context.Context, string, string, int64) ([]*gh.WorkflowJob, error) { + return nil, nil +} + +type workflowDispatchOnlyFake struct{ Client } + +func (*workflowDispatchOnlyFake) DispatchManualWorkflow(context.Context, string, string, int64, gh.CreateWorkflowDispatchEventRequest) (*gh.WorkflowDispatchRunDetails, error) { + return nil, nil +} + +func TestGitHubWorkflowCapabilitiesAreIndependent(t *testing.T) { + tests := []struct { + name string + client Client + readCatalog bool + readRuns bool + dispatch bool + }{ + {name: "catalog", client: &workflowCatalogOnlyFake{}, readCatalog: true}, + {name: "runs", client: &workflowRunOnlyFake{}, readRuns: true}, + {name: "dispatch", client: &workflowDispatchOnlyFake{}, dispatch: true}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + provider := &gitHubClientProvider{host: "github.com", client: test.client} + caps := provider.Capabilities() + assert.Equal(t, test.readCatalog, caps.ReadWorkflows) + assert.Equal(t, test.readRuns, caps.ReadWorkflowRuns) + assert.Equal(t, test.dispatch, caps.WorkflowDispatch) + if !test.readCatalog { + _, err := provider.ListManualWorkflows(t.Context(), platform.RepoRef{}) + require.ErrorIs(t, err, platform.ErrUnsupportedCapability) + _, err = provider.ListWorkflowEnvironments(t.Context(), platform.RepoRef{}) + require.ErrorIs(t, err, platform.ErrUnsupportedCapability) + } + if !test.readRuns { + _, err := provider.ListWorkflowRuns(t.Context(), platform.RepoRef{}, platform.WorkflowRunQuery{}) + require.ErrorIs(t, err, platform.ErrUnsupportedCapability) + _, err = provider.ListWorkflowRunJobs(t.Context(), platform.RepoRef{}, "1") + require.ErrorIs(t, err, platform.ErrUnsupportedCapability) + } + if !test.dispatch { + _, err := provider.DispatchWorkflow(t.Context(), platform.RepoRef{}, platform.WorkflowDispatchRequest{}) + require.ErrorIs(t, err, platform.ErrUnsupportedCapability) + } + }) + } +} + func TestGitHubWorkflowProviderCatalogPreservesPartialAvailability(t *testing.T) { fake := &workflowProviderFake{ workflows: []*gh.Workflow{ {ID: gh.Ptr(int64(1)), Name: gh.Ptr("Release"), Path: gh.Ptr(".github/workflows/release.yml"), State: gh.Ptr("active"), HTMLURL: gh.Ptr("https://example.test/release")}, {ID: gh.Ptr(int64(2)), Name: gh.Ptr("Broken"), Path: gh.Ptr(".github/workflows/broken.yml"), State: gh.Ptr("active")}, {ID: gh.Ptr(int64(3)), Name: gh.Ptr("CI"), Path: gh.Ptr(".github/workflows/ci.yml"), State: gh.Ptr("active")}, + {ID: gh.Ptr(int64(5)), Name: gh.Ptr("Malformed"), Path: gh.Ptr(".github/workflows/malformed.yml"), State: gh.Ptr("active")}, {ID: gh.Ptr(int64(4)), Name: gh.Ptr("Disabled"), Path: gh.Ptr("disabled.yml"), State: gh.Ptr("disabled_manually")}, }, definitions: map[string]string{ - ".github/workflows/release.yml": "on:\n workflow_dispatch:\n inputs:\n target:\n type: environment\n", - ".github/workflows/ci.yml": "on: [push]\n", + ".github/workflows/release.yml": "on:\n workflow_dispatch:\n inputs:\n target:\n type: environment\n", + ".github/workflows/ci.yml": "on: [push]\n", + ".github/workflows/malformed.yml": "on: workflow_dispatch\n---\non: workflow_dispatch\n", }, environments: []*gh.Environment{{Name: gh.Ptr("production")}}, } @@ -78,19 +146,41 @@ func TestGitHubWorkflowProviderCatalogPreservesPartialAvailability(t *testing.T) catalog, err := provider.ListManualWorkflows(t.Context(), platform.RepoRef{Owner: "acme", Name: "widgets"}) require.NoError(t, err) - require.Len(t, catalog, 2) + require.Len(t, catalog, 3) assert.Equal(t, "1", catalog[0].ID) assert.True(t, catalog[0].Available) assert.Equal(t, "2", catalog[1].ID) assert.False(t, catalog[1].Available) assert.NotEmpty(t, catalog[1].UnavailableReason) - assert.Equal(t, []string{"trunk", "trunk", "trunk"}, fake.definitionRefs) + assert.Equal(t, "5", catalog[2].ID) + assert.False(t, catalog[2].Available) + assert.NotEmpty(t, catalog[2].UnavailableReason) + assert.Equal(t, []string{"trunk", "trunk", "trunk", "trunk"}, fake.definitionRefs) environments, err := provider.ListWorkflowEnvironments(t.Context(), platform.RepoRef{Owner: "acme", Name: "widgets"}) require.NoError(t, err) assert.Equal(t, []platform.WorkflowEnvironment{{Name: "production"}}, environments) } +func TestGitHubWorkflowEnvironmentsSkipTransportWithoutEnvironmentInput(t *testing.T) { + fake := &workflowProviderFake{ + workflows: []*gh.Workflow{{ + ID: gh.Ptr(int64(1)), Name: gh.Ptr("Release"), + Path: gh.Ptr(".github/workflows/release.yml"), State: gh.Ptr("active"), + }}, + definitions: map[string]string{ + ".github/workflows/release.yml": "on:\n workflow_dispatch:\n inputs:\n version:\n type: string\n", + }, + } + provider := &gitHubClientProvider{host: "github.com", client: fake} + environments, err := provider.ListWorkflowEnvironments( + t.Context(), platform.RepoRef{Owner: "acme", Name: "widgets"}, + ) + require.NoError(t, err) + assert.Empty(t, environments) + assert.NotContains(t, fake.calls, "environments") +} + func TestGitHubWorkflowProviderNormalizesRunsJobsAndDispatch(t *testing.T) { created := time.Date(2026, 8, 27, 12, 0, 0, 0, time.FixedZone("offset", 3600)) updated := created.Add(time.Minute) @@ -108,28 +198,38 @@ func TestGitHubWorkflowProviderNormalizesRunsJobsAndDispatch(t *testing.T) { provider := &gitHubClientProvider{host: "github.com", client: fake} page, err := provider.ListWorkflowRuns(t.Context(), platform.RepoRef{Owner: "acme", Name: "widgets"}, platform.WorkflowRunQuery{WorkflowID: "42"}) require.NoError(t, err) - require.Len(t, page.Items, 1) - assert.Equal(t, "100", page.Items[0].ID) - assert.Equal(t, "octocat", page.Items[0].Actor) - assert.Equal(t, created.UTC(), page.Items[0].CreatedAt) - assert.Equal(t, "3", page.NextCursor) + assert.Equal(t, platform.Page[platform.WorkflowRun]{ + NextCursor: "3", + Items: []platform.WorkflowRun{{ + ID: "100", WorkflowID: "42", RunNumber: 7, Name: "Release", + Event: "workflow_dispatch", Ref: "main", HeadSHA: "abc", Actor: "octocat", + Status: "completed", Conclusion: "success", CreatedAt: created.UTC(), + UpdatedAt: updated.UTC(), WebURL: "https://example.test/runs/100", + }}, + }, page) jobs, err := provider.ListWorkflowRunJobs(t.Context(), platform.RepoRef{Owner: "acme", Name: "widgets"}, "100") require.NoError(t, err) - require.Len(t, jobs, 1) - assert.Equal(t, "9", jobs[0].ID) - require.Len(t, jobs[0].Steps, 1) - assert.Equal(t, 1, jobs[0].Steps[0].Number) + assert.Equal(t, []platform.WorkflowRunJob{{ + ID: "9", Name: "deploy", Status: "completed", Conclusion: "success", + StartedAt: started.UTC(), CompletedAt: completed.UTC(), + WebURL: "https://example.test/jobs/9", + Steps: []platform.WorkflowRunStep{{ + Number: 1, Name: "ship", Status: "completed", Conclusion: "success", + StartedAt: started.UTC(), CompletedAt: completed.UTC(), + }}, + }}, jobs) result, err := provider.DispatchWorkflow(t.Context(), platform.RepoRef{Owner: "acme", Name: "widgets"}, platform.WorkflowDispatchRequest{WorkflowID: "42", Ref: "main"}) require.NoError(t, err) - assert.True(t, result.Accepted) - assert.False(t, result.LocatingRun) - require.NotNil(t, result.Run) - assert.Equal(t, "101", result.Run.ID) - + assert.Equal(t, platform.WorkflowDispatchResult{ + Accepted: true, + Run: &platform.WorkflowRun{ + ID: "101", WorkflowID: "42", WebURL: "https://example.test/runs/101", + }, + }, result) fake.dispatch = nil result, err = provider.DispatchWorkflow(t.Context(), platform.RepoRef{Owner: "acme", Name: "widgets"}, platform.WorkflowDispatchRequest{WorkflowID: "42", Ref: "main"}) require.NoError(t, err) - assert.True(t, result.LocatingRun) + assert.Equal(t, platform.WorkflowDispatchResult{Accepted: true, LocatingRun: true}, result) _, err = provider.DispatchWorkflow(t.Context(), platform.RepoRef{}, platform.WorkflowDispatchRequest{WorkflowID: "not-decimal"}) require.ErrorIs(t, err, platform.ErrInvalidArgument) } From 41b84b514cff98152a0f43e343b72520d0a88f0d Mon Sep 17 00:00:00 2001 From: Max Mill Date: Fri, 28 Aug 2026 16:46:59 +0200 Subject: [PATCH 08/41] feat: expose provider workflow action routes Huma routes validate live workflow definitions and dispatch inputs behind repository identity, capability, credential, and rate-limit gates. --- context/provider-architecture.md | 3 + frontend/openapi/openapi.yaml | 715 ++++++++- frontend/src/lib/api/generated/schema.ts | 557 +++++++ internal/apiclient/generated/client.gen.go | 1688 +++++++++++++++++++- internal/server/api_test.go | 39 + internal/server/huma_routes.go | 1 + internal/server/pullapi/routes.go | 19 + internal/server/pullapi/types.go | 9 + internal/server/route_metadata_test.go | 1 + internal/server/server.go | 8 + internal/server/workflowapi/handler.go | 55 + internal/server/workflowapi/routes.go | 450 ++++++ internal/server/workflowapi/routes_test.go | 332 ++++ internal/server/workflowapi/types.go | 175 ++ 14 files changed, 3997 insertions(+), 55 deletions(-) create mode 100644 internal/server/workflowapi/handler.go create mode 100644 internal/server/workflowapi/routes.go create mode 100644 internal/server/workflowapi/routes_test.go create mode 100644 internal/server/workflowapi/types.go diff --git a/context/provider-architecture.md b/context/provider-architecture.md index 5da544d54b..43c76657d8 100644 --- a/context/provider-architecture.md +++ b/context/provider-architecture.md @@ -68,6 +68,9 @@ Rules: - Keep workflow approval and workflow dispatch separate: approval advances an existing run, while dispatch starts a new one; neither contract may assume GitHub Actions semantics (`internal/platform/client.go::WorkflowDispatcher`). +- Treat workflow dispatch as live-state mutation: re-read definitions/environments, + validate SHA and typed inputs, then gate one provider call under a stable route fence; + never retry uncertain writes or persist definitions (`internal/server/workflowapi/routes.go::Handler.dispatch`). - Provider-backed comment deletes remove synchronized local rows only after upstream synchronization observes provider absence. DELETE itself changes no SQLite comment state; the UI hides a confirmed deletion while ordinary sync converges. Authoritative diff --git a/frontend/openapi/openapi.yaml b/frontend/openapi/openapi.yaml index 3723ec04c9..595fef22ef 100644 --- a/frontend/openapi/openapi.yaml +++ b/frontend/openapi/openapi.yaml @@ -4129,6 +4129,12 @@ components: type: - array - "null" + head_repo_kind: + enum: + - same_repo + - fork + - unknown + type: string merge_base_sha: type: string merge_request: @@ -4173,6 +4179,7 @@ components: - repo_name - platform_host - platform_head_sha + - head_repo_kind - platform_base_sha - reviewed_head_sha - diff_head_sha @@ -7712,6 +7719,304 @@ components: - required - count type: object + WorkflowCatalogResponse: + additionalProperties: false + properties: + $schema: + description: A URL to the JSON Schema for this object. + examples: + - /api/v1/schemas/WorkflowCatalogResponse.json + format: uri + readOnly: true + type: string + environments: + items: + $ref: "#/components/schemas/WorkflowEnvironmentResponse" + type: + - array + - "null" + repo: + $ref: "#/components/schemas/RepoRefResponse" + workflows: + items: + $ref: "#/components/schemas/WorkflowDefinitionResponse" + type: + - array + - "null" + required: + - repo + - workflows + - environments + type: object + WorkflowDefinitionResponse: + additionalProperties: false + properties: + available: + type: boolean + definition_sha: + type: string + id: + type: string + inputs: + items: + $ref: "#/components/schemas/WorkflowInputResponse" + type: + - array + - "null" + name: + type: string + path: + type: string + state: + type: string + unavailable_reason: + type: string + web_url: + format: uri + type: string + required: + - id + - name + - path + - state + - web_url + - definition_sha + - inputs + - available + type: object + WorkflowDispatchBody: + additionalProperties: false + properties: + $schema: + description: A URL to the JSON Schema for this object. + examples: + - /api/v1/schemas/WorkflowDispatchBody.json + format: uri + readOnly: true + type: string + expected_definition_sha: + type: string + inputs: + additionalProperties: {} + type: object + ref: + type: string + required: + - ref + - inputs + - expected_definition_sha + type: object + WorkflowDispatchResponse: + additionalProperties: false + properties: + $schema: + description: A URL to the JSON Schema for this object. + examples: + - /api/v1/schemas/WorkflowDispatchResponse.json + format: uri + readOnly: true + type: string + accepted: + type: boolean + locating_run: + type: boolean + run: + $ref: "#/components/schemas/WorkflowRunResponse" + required: + - accepted + - locating_run + type: object + WorkflowEnvironmentResponse: + additionalProperties: false + properties: + name: + type: string + required: + - name + type: object + WorkflowInputResponse: + additionalProperties: false + properties: + default: {} + description: + type: string + has_default: + type: boolean + name: + type: string + options: + items: + type: string + type: + - array + - "null" + required: + type: boolean + type: + enum: + - string + - number + - boolean + - choice + - environment + type: string + required: + - name + - required + - type + - has_default + type: object + WorkflowJobsResponse: + additionalProperties: false + properties: + $schema: + description: A URL to the JSON Schema for this object. + examples: + - /api/v1/schemas/WorkflowJobsResponse.json + format: uri + readOnly: true + type: string + items: + items: + $ref: "#/components/schemas/WorkflowRunJobResponse" + type: + - array + - "null" + repo: + $ref: "#/components/schemas/RepoRefResponse" + required: + - repo + - items + type: object + WorkflowRunJobResponse: + additionalProperties: false + properties: + completed_at: + format: date-time + type: string + conclusion: + type: string + id: + type: string + name: + type: string + started_at: + format: date-time + type: string + status: + type: string + steps: + items: + $ref: "#/components/schemas/WorkflowRunStepResponse" + type: + - array + - "null" + web_url: + format: uri + type: string + required: + - id + - name + - status + - conclusion + - steps + type: object + WorkflowRunResponse: + additionalProperties: false + properties: + actor: + type: string + conclusion: + type: string + created_at: + format: date-time + type: string + event: + type: string + head_sha: + type: string + id: + type: string + name: + type: string + ref: + type: string + run_number: + format: int64 + type: integer + status: + type: string + updated_at: + format: date-time + type: string + web_url: + format: uri + type: string + workflow_id: + type: string + required: + - id + - workflow_id + - run_number + - name + - event + - ref + - head_sha + - actor + - status + - conclusion + type: object + WorkflowRunStepResponse: + additionalProperties: false + properties: + completed_at: + format: date-time + type: string + conclusion: + type: string + name: + type: string + number: + format: int64 + type: integer + started_at: + format: date-time + type: string + status: + type: string + required: + - number + - name + - status + - conclusion + type: object + WorkflowRunsResponse: + additionalProperties: false + properties: + $schema: + description: A URL to the JSON Schema for this object. + examples: + - /api/v1/schemas/WorkflowRunsResponse.json + format: uri + readOnly: true + type: string + exhausted: + type: boolean + items: + items: + $ref: "#/components/schemas/WorkflowRunResponse" + type: + - array + - "null" + next_cursor: + type: string + repo: + $ref: "#/components/schemas/RepoRefResponse" + required: + - repo + - items + - exhausted + type: object WorkflowStateMetaResponse: additionalProperties: false properties: @@ -8254,20 +8559,205 @@ info: version: 0.1.0 openapi: 3.1.0 paths: - /activity: + /actions/{provider}/{owner}/{name}/runs: get: - operationId: list-activity + operationId: list-workflow-runs parameters: - - description: Repository filter. Accepts provider|platform_host/repo_path, with comma-separated values for multiple repositories. - explode: false - in: query - name: repo + - in: path + name: provider + required: true schema: - description: Repository filter. Accepts provider|platform_host/repo_path, with comma-separated values for multiple repositories. type: string - - explode: false - in: query - name: types + - in: path + name: owner + required: true + schema: + type: string + - in: path + name: name + required: true + schema: + type: string + - explode: false + in: query + name: workflow_id + schema: + type: string + - explode: false + in: query + name: event + schema: + type: string + - explode: false + in: query + name: branch + schema: + type: string + - explode: false + in: query + name: cursor + schema: + type: string + - explode: false + in: query + name: per_page + schema: + default: 20 + format: int64 + maximum: 100 + minimum: 1 + type: integer + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/WorkflowRunsResponse" + description: OK + default: + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ProblemError" + description: Error + summary: List workflow runs + tags: + - Workflows + /actions/{provider}/{owner}/{name}/runs/{run_id}/jobs: + get: + operationId: list-workflow-run-jobs + parameters: + - in: path + name: provider + required: true + schema: + type: string + - in: path + name: owner + required: true + schema: + type: string + - in: path + name: name + required: true + schema: + type: string + - in: path + name: run_id + required: true + schema: + type: string + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/WorkflowJobsResponse" + description: OK + default: + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ProblemError" + description: Error + summary: List workflow run jobs + tags: + - Workflows + /actions/{provider}/{owner}/{name}/workflows: + get: + operationId: list-workflows + parameters: + - in: path + name: provider + required: true + schema: + type: string + - in: path + name: owner + required: true + schema: + type: string + - in: path + name: name + required: true + schema: + type: string + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/WorkflowCatalogResponse" + description: OK + default: + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ProblemError" + description: Error + summary: List manual workflows + tags: + - Workflows + /actions/{provider}/{owner}/{name}/workflows/{workflow_id}/dispatch: + post: + operationId: dispatch-workflow + parameters: + - in: path + name: provider + required: true + schema: + type: string + - in: path + name: owner + required: true + schema: + type: string + - in: path + name: name + required: true + schema: + type: string + - in: path + name: workflow_id + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/WorkflowDispatchBody" + required: true + responses: + "202": + content: + application/json: + schema: + $ref: "#/components/schemas/WorkflowDispatchResponse" + description: Accepted + default: + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ProblemError" + description: Error + summary: Dispatch workflow + tags: + - Workflows + /activity: + get: + operationId: list-activity + parameters: + - description: Repository filter. Accepts provider|platform_host/repo_path, with comma-separated values for multiple repositories. + explode: false + in: query + name: repo + schema: + description: Repository filter. Accepts provider|platform_host/repo_path, with comma-separated values for multiple repositories. + type: string + - explode: false + in: query + name: types schema: items: type: string @@ -10833,6 +11323,211 @@ paths: summary: Get workspace session attach spec on fleet host tags: - Fleet + /host/{platform_host}/actions/{provider}/{owner}/{name}/runs: + get: + operationId: list-workflow-runs-on-host + parameters: + - in: path + name: provider + required: true + schema: + type: string + - in: path + name: platform_host + required: true + schema: + type: string + - in: path + name: owner + required: true + schema: + type: string + - in: path + name: name + required: true + schema: + type: string + - explode: false + in: query + name: workflow_id + schema: + type: string + - explode: false + in: query + name: event + schema: + type: string + - explode: false + in: query + name: branch + schema: + type: string + - explode: false + in: query + name: cursor + schema: + type: string + - explode: false + in: query + name: per_page + schema: + default: 20 + format: int64 + maximum: 100 + minimum: 1 + type: integer + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/WorkflowRunsResponse" + description: OK + default: + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ProblemError" + description: Error + summary: List workflow runs + tags: + - Workflows + /host/{platform_host}/actions/{provider}/{owner}/{name}/runs/{run_id}/jobs: + get: + operationId: list-workflow-run-jobs-on-host + parameters: + - in: path + name: provider + required: true + schema: + type: string + - in: path + name: platform_host + required: true + schema: + type: string + - in: path + name: owner + required: true + schema: + type: string + - in: path + name: name + required: true + schema: + type: string + - in: path + name: run_id + required: true + schema: + type: string + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/WorkflowJobsResponse" + description: OK + default: + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ProblemError" + description: Error + summary: List workflow run jobs + tags: + - Workflows + /host/{platform_host}/actions/{provider}/{owner}/{name}/workflows: + get: + operationId: list-workflows-on-host + parameters: + - in: path + name: provider + required: true + schema: + type: string + - in: path + name: platform_host + required: true + schema: + type: string + - in: path + name: owner + required: true + schema: + type: string + - in: path + name: name + required: true + schema: + type: string + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/WorkflowCatalogResponse" + description: OK + default: + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ProblemError" + description: Error + summary: List manual workflows + tags: + - Workflows + /host/{platform_host}/actions/{provider}/{owner}/{name}/workflows/{workflow_id}/dispatch: + post: + operationId: dispatch-workflow-on-host + parameters: + - in: path + name: provider + required: true + schema: + type: string + - in: path + name: platform_host + required: true + schema: + type: string + - in: path + name: owner + required: true + schema: + type: string + - in: path + name: name + required: true + schema: + type: string + - in: path + name: workflow_id + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/WorkflowDispatchBody" + required: true + responses: + "202": + content: + application/json: + schema: + $ref: "#/components/schemas/WorkflowDispatchResponse" + description: Accepted + default: + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ProblemError" + description: Error + summary: Dispatch workflow + tags: + - Workflows /host/{platform_host}/issues/{provider}/{owner}/{name}: post: operationId: create-issue-on-host diff --git a/frontend/src/lib/api/generated/schema.ts b/frontend/src/lib/api/generated/schema.ts index bdc611a405..9d467fbfe0 100644 --- a/frontend/src/lib/api/generated/schema.ts +++ b/frontend/src/lib/api/generated/schema.ts @@ -4,6 +4,74 @@ */ export interface paths { + "/actions/{provider}/{owner}/{name}/runs": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List workflow runs */ + get: operations["list-workflow-runs"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/actions/{provider}/{owner}/{name}/runs/{run_id}/jobs": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List workflow run jobs */ + get: operations["list-workflow-run-jobs"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/actions/{provider}/{owner}/{name}/workflows": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List manual workflows */ + get: operations["list-workflows"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/actions/{provider}/{owner}/{name}/workflows/{workflow_id}/dispatch": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Dispatch workflow */ + post: operations["dispatch-workflow"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/activity": { parameters: { query?: never; @@ -1102,6 +1170,74 @@ export interface paths { patch?: never; trace?: never; }; + "/host/{platform_host}/actions/{provider}/{owner}/{name}/runs": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List workflow runs */ + get: operations["list-workflow-runs-on-host"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/host/{platform_host}/actions/{provider}/{owner}/{name}/runs/{run_id}/jobs": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List workflow run jobs */ + get: operations["list-workflow-run-jobs-on-host"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/host/{platform_host}/actions/{provider}/{owner}/{name}/workflows": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List manual workflows */ + get: operations["list-workflows-on-host"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/host/{platform_host}/actions/{provider}/{owner}/{name}/workflows/{workflow_id}/dispatch": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Dispatch workflow */ + post: operations["dispatch-workflow-on-host"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/host/{platform_host}/issues/{provider}/{owner}/{name}": { parameters: { query?: never; @@ -6545,6 +6681,8 @@ export interface components { detail_loaded: boolean; diff_head_sha: string; events: components["schemas"]["MergeRequestEventResponse"][] | null; + /** @enum {string} */ + head_repo_kind: "same_repo" | "fork" | "unknown"; merge_base_sha: string; merge_request: components["schemas"]["MergeRequest"]; platform_base_sha: string; @@ -8137,6 +8275,131 @@ export interface components { count: number; required: boolean; }; + WorkflowCatalogResponse: { + /** + * Format: uri + * @description A URL to the JSON Schema for this object. + * @example /api/v1/schemas/WorkflowCatalogResponse.json + */ + readonly $schema?: string; + environments: components["schemas"]["WorkflowEnvironmentResponse"][] | null; + repo: components["schemas"]["RepoRefResponse"]; + workflows: components["schemas"]["WorkflowDefinitionResponse"][] | null; + }; + WorkflowDefinitionResponse: { + available: boolean; + definition_sha: string; + id: string; + inputs: components["schemas"]["WorkflowInputResponse"][] | null; + name: string; + path: string; + state: string; + unavailable_reason?: string; + /** Format: uri */ + web_url: string; + }; + WorkflowDispatchBody: { + /** + * Format: uri + * @description A URL to the JSON Schema for this object. + * @example /api/v1/schemas/WorkflowDispatchBody.json + */ + readonly $schema?: string; + expected_definition_sha: string; + inputs: { + [key: string]: unknown; + }; + ref: string; + }; + WorkflowDispatchResponse: { + /** + * Format: uri + * @description A URL to the JSON Schema for this object. + * @example /api/v1/schemas/WorkflowDispatchResponse.json + */ + readonly $schema?: string; + accepted: boolean; + locating_run: boolean; + run?: components["schemas"]["WorkflowRunResponse"]; + }; + WorkflowEnvironmentResponse: { + name: string; + }; + WorkflowInputResponse: { + default?: unknown; + description?: string; + has_default: boolean; + name: string; + options?: string[] | null; + required: boolean; + /** @enum {string} */ + type: "string" | "number" | "boolean" | "choice" | "environment"; + }; + WorkflowJobsResponse: { + /** + * Format: uri + * @description A URL to the JSON Schema for this object. + * @example /api/v1/schemas/WorkflowJobsResponse.json + */ + readonly $schema?: string; + items: components["schemas"]["WorkflowRunJobResponse"][] | null; + repo: components["schemas"]["RepoRefResponse"]; + }; + WorkflowRunJobResponse: { + /** Format: date-time */ + completed_at?: string; + conclusion: string; + id: string; + name: string; + /** Format: date-time */ + started_at?: string; + status: string; + steps: components["schemas"]["WorkflowRunStepResponse"][] | null; + /** Format: uri */ + web_url?: string; + }; + WorkflowRunResponse: { + actor: string; + conclusion: string; + /** Format: date-time */ + created_at?: string; + event: string; + head_sha: string; + id: string; + name: string; + ref: string; + /** Format: int64 */ + run_number: number; + status: string; + /** Format: date-time */ + updated_at?: string; + /** Format: uri */ + web_url?: string; + workflow_id: string; + }; + WorkflowRunStepResponse: { + /** Format: date-time */ + completed_at?: string; + conclusion: string; + name: string; + /** Format: int64 */ + number: number; + /** Format: date-time */ + started_at?: string; + status: string; + }; + WorkflowRunsResponse: { + /** + * Format: uri + * @description A URL to the JSON Schema for this object. + * @example /api/v1/schemas/WorkflowRunsResponse.json + */ + readonly $schema?: string; + exhausted: boolean; + items: components["schemas"]["WorkflowRunResponse"][] | null; + next_cursor?: string; + repo: components["schemas"]["RepoRefResponse"]; + }; WorkflowStateMetaResponse: { /** @enum {string} */ status: "new" | "reviewing" | "waiting" | "awaiting_merge"; @@ -8385,6 +8648,150 @@ export interface components { } export type $defs = Record; export interface operations { + "list-workflow-runs": { + parameters: { + query?: { + workflow_id?: string; + event?: string; + branch?: string; + cursor?: string; + per_page?: number; + }; + header?: never; + path: { + provider: string; + owner: string; + name: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WorkflowRunsResponse"]; + }; + }; + /** @description Error */ + default: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemError"]; + }; + }; + }; + }; + "list-workflow-run-jobs": { + parameters: { + query?: never; + header?: never; + path: { + provider: string; + owner: string; + name: string; + run_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WorkflowJobsResponse"]; + }; + }; + /** @description Error */ + default: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemError"]; + }; + }; + }; + }; + "list-workflows": { + parameters: { + query?: never; + header?: never; + path: { + provider: string; + owner: string; + name: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WorkflowCatalogResponse"]; + }; + }; + /** @description Error */ + default: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemError"]; + }; + }; + }; + }; + "dispatch-workflow": { + parameters: { + query?: never; + header?: never; + path: { + provider: string; + owner: string; + name: string; + workflow_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["WorkflowDispatchBody"]; + }; + }; + responses: { + /** @description Accepted */ + 202: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WorkflowDispatchResponse"]; + }; + }; + /** @description Error */ + default: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemError"]; + }; + }; + }; + }; "list-activity": { parameters: { query?: { @@ -10707,6 +11114,154 @@ export interface operations { }; }; }; + "list-workflow-runs-on-host": { + parameters: { + query?: { + workflow_id?: string; + event?: string; + branch?: string; + cursor?: string; + per_page?: number; + }; + header?: never; + path: { + provider: string; + platform_host: string; + owner: string; + name: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WorkflowRunsResponse"]; + }; + }; + /** @description Error */ + default: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemError"]; + }; + }; + }; + }; + "list-workflow-run-jobs-on-host": { + parameters: { + query?: never; + header?: never; + path: { + provider: string; + platform_host: string; + owner: string; + name: string; + run_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WorkflowJobsResponse"]; + }; + }; + /** @description Error */ + default: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemError"]; + }; + }; + }; + }; + "list-workflows-on-host": { + parameters: { + query?: never; + header?: never; + path: { + provider: string; + platform_host: string; + owner: string; + name: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WorkflowCatalogResponse"]; + }; + }; + /** @description Error */ + default: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemError"]; + }; + }; + }; + }; + "dispatch-workflow-on-host": { + parameters: { + query?: never; + header?: never; + path: { + provider: string; + platform_host: string; + owner: string; + name: string; + workflow_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["WorkflowDispatchBody"]; + }; + }; + responses: { + /** @description Accepted */ + 202: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WorkflowDispatchResponse"]; + }; + }; + /** @description Error */ + default: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemError"]; + }; + }; + }; + }; "create-issue-on-host": { parameters: { query?: never; @@ -19191,10 +19746,12 @@ export const kataEffectiveLinksResponseStateValues: ReadonlyArray["schemas"]["KataWorkspaceTargetResponse"]["resolution_status"]> = ["mapped", "unmapped", "ambiguous", "invalid", "error"]; export const mergeRequestKanbanStatusValues: ReadonlyArray["schemas"]["MergeRequest"]["KanbanStatus"]> = ["new", "reviewing", "waiting", "awaiting_merge"]; export const mergeRequestStateValues: ReadonlyArray["schemas"]["MergeRequest"]["State"]> = ["open", "closed", "merged"]; +export const mergeRequestDetailResponseHead_repo_kindValues: ReadonlyArray["schemas"]["MergeRequestDetailResponse"]["head_repo_kind"]> = ["same_repo", "fork", "unknown"]; export const mergeRequestResponseKanbanStatusValues: ReadonlyArray["schemas"]["MergeRequestResponse"]["KanbanStatus"]> = ["new", "reviewing", "waiting", "awaiting_merge"]; export const mergeRequestResponseStateValues: ReadonlyArray["schemas"]["MergeRequestResponse"]["State"]> = ["open", "closed", "merged"]; export const problemErrorCodeValues: ReadonlyArray["schemas"]["ProblemError"]["code"]> = ["badRequest", "branchConflict", "branchInUse", "branchProtected", "commentNotFound", "conflict", "destinationExists", "forbidden", "hookFailed", "internalError", "issueNotFound", "mutationOutcomeUnknown", "notFound", "payloadTooLarge", "projectNotFound", "pullNotFound", "rateLimited", "repoNotFound", "resyncRequired", "serviceUnavailable", "settingsUnavailable", "toolMissing", "toolUnauthenticated", "unauthorized", "unsupportedCapability", "upstreamError", "validationError", "workspaceAlreadyExists", "workspaceDeletionInProgress", "workspaceDirectoryNotReusable", "workspaceNotFound", "workspaceSetupInProgress", "worktreeDirty"]; export const syncStatusLast_error_codeValues: ReadonlyArray["schemas"]["SyncStatus"]["last_error_code"]> = ["localSyncCeilingExhausted"]; +export const workflowInputResponseTypeValues: ReadonlyArray["schemas"]["WorkflowInputResponse"]["type"]> = ["string", "number", "boolean", "choice", "environment"]; export const workflowStateMetaResponseStatusValues: ReadonlyArray["schemas"]["WorkflowStateMetaResponse"]["status"]> = ["new", "reviewing", "waiting", "awaiting_merge"]; export const workspaceResponseAgent_stateValues: ReadonlyArray["schemas"]["WorkspaceResponse"]["agent_state"]> = ["idle", "working", "input", "approval", "done"]; export const workspaceResponseEnrichment_statusValues: ReadonlyArray["schemas"]["WorkspaceResponse"]["enrichment_status"]> = ["not_applicable", "pending", "fresh", "stale", "failed"]; diff --git a/internal/apiclient/generated/client.gen.go b/internal/apiclient/generated/client.gen.go index 342cca5ef2..db476d2537 100644 --- a/internal/apiclient/generated/client.gen.go +++ b/internal/apiclient/generated/client.gen.go @@ -614,6 +614,27 @@ func (e MergeRequestState) Valid() bool { } } +// Defines values for MergeRequestDetailResponseHeadRepoKind. +const ( + MergeRequestDetailResponseHeadRepoKindFork MergeRequestDetailResponseHeadRepoKind = "fork" + MergeRequestDetailResponseHeadRepoKindSameRepo MergeRequestDetailResponseHeadRepoKind = "same_repo" + MergeRequestDetailResponseHeadRepoKindUnknown MergeRequestDetailResponseHeadRepoKind = "unknown" +) + +// Valid indicates whether the value is a known member of the MergeRequestDetailResponseHeadRepoKind enum. +func (e MergeRequestDetailResponseHeadRepoKind) Valid() bool { + switch e { + case MergeRequestDetailResponseHeadRepoKindFork: + return true + case MergeRequestDetailResponseHeadRepoKindSameRepo: + return true + case MergeRequestDetailResponseHeadRepoKindUnknown: + return true + default: + return false + } +} + // Defines values for MergeRequestResponseKanbanStatus. const ( MergeRequestResponseKanbanStatusAwaitingMerge MergeRequestResponseKanbanStatus = "awaiting_merge" @@ -785,6 +806,33 @@ func (e SyncStatusLastErrorCode) Valid() bool { } } +// Defines values for WorkflowInputResponseType. +const ( + Boolean WorkflowInputResponseType = "boolean" + Choice WorkflowInputResponseType = "choice" + Environment WorkflowInputResponseType = "environment" + Number WorkflowInputResponseType = "number" + String WorkflowInputResponseType = "string" +) + +// Valid indicates whether the value is a known member of the WorkflowInputResponseType enum. +func (e WorkflowInputResponseType) Valid() bool { + switch e { + case Boolean: + return true + case Choice: + return true + case Environment: + return true + case Number: + return true + case String: + return true + default: + return false + } +} + // Defines values for WorkflowStateMetaResponseStatus. const ( WorkflowStateMetaResponseStatusAwaitingMerge WorkflowStateMetaResponseStatus = "awaiting_merge" @@ -3007,28 +3055,32 @@ type MergeRequestDetailResponse struct { // Schema A URL to the JSON Schema for this object. // // Example: /api/v1/schemas/MergeRequestDetailResponse.json - Schema *string `json:"$schema,omitempty"` - Checks *[]CICheck `json:"checks,omitempty"` - DeferredMergePending bool `json:"deferred_merge_pending"` - DetailFetchedAt *string `json:"detail_fetched_at,omitempty"` - DetailLoaded bool `json:"detail_loaded"` - DiffHeadSha string `json:"diff_head_sha"` - Events *[]MergeRequestEventResponse `json:"events"` - MergeBaseSha string `json:"merge_base_sha"` - MergeRequest MergeRequest `json:"merge_request"` - PlatformBaseSha string `json:"platform_base_sha"` - PlatformHeadSha string `json:"platform_head_sha"` - PlatformHost string `json:"platform_host"` - Repo RepoRefResponse `json:"repo"` - RepoName string `json:"repo_name"` - RepoOwner string `json:"repo_owner"` - ReviewedHeadSha string `json:"reviewed_head_sha"` - Stack *StackContextResponse `json:"stack,omitempty"` - Warnings *[]string `json:"warnings,omitempty"` - WorkflowApproval WorkflowApprovalResponse `json:"workflow_approval"` - Workspace *WorkspaceRef `json:"workspace,omitempty"` - WorktreeLinks *[]WorktreeLinkResponse `json:"worktree_links"` -} + Schema *string `json:"$schema,omitempty"` + Checks *[]CICheck `json:"checks,omitempty"` + DeferredMergePending bool `json:"deferred_merge_pending"` + DetailFetchedAt *string `json:"detail_fetched_at,omitempty"` + DetailLoaded bool `json:"detail_loaded"` + DiffHeadSha string `json:"diff_head_sha"` + Events *[]MergeRequestEventResponse `json:"events"` + HeadRepoKind MergeRequestDetailResponseHeadRepoKind `json:"head_repo_kind"` + MergeBaseSha string `json:"merge_base_sha"` + MergeRequest MergeRequest `json:"merge_request"` + PlatformBaseSha string `json:"platform_base_sha"` + PlatformHeadSha string `json:"platform_head_sha"` + PlatformHost string `json:"platform_host"` + Repo RepoRefResponse `json:"repo"` + RepoName string `json:"repo_name"` + RepoOwner string `json:"repo_owner"` + ReviewedHeadSha string `json:"reviewed_head_sha"` + Stack *StackContextResponse `json:"stack,omitempty"` + Warnings *[]string `json:"warnings,omitempty"` + WorkflowApproval WorkflowApprovalResponse `json:"workflow_approval"` + Workspace *WorkspaceRef `json:"workspace,omitempty"` + WorktreeLinks *[]WorktreeLinkResponse `json:"worktree_links"` +} + +// MergeRequestDetailResponseHeadRepoKind defines model for MergeRequestDetailResponse.HeadRepoKind. +type MergeRequestDetailResponseHeadRepoKind string // MergeRequestEventResponse defines model for MergeRequestEventResponse. type MergeRequestEventResponse struct { @@ -4604,6 +4656,132 @@ type WorkflowApprovalResponse struct { Required bool `json:"required"` } +// WorkflowCatalogResponse defines model for WorkflowCatalogResponse. +type WorkflowCatalogResponse struct { + // Schema A URL to the JSON Schema for this object. + // + // Example: /api/v1/schemas/WorkflowCatalogResponse.json + Schema *string `json:"$schema,omitempty"` + Environments *[]WorkflowEnvironmentResponse `json:"environments"` + Repo RepoRefResponse `json:"repo"` + Workflows *[]WorkflowDefinitionResponse `json:"workflows"` +} + +// WorkflowDefinitionResponse defines model for WorkflowDefinitionResponse. +type WorkflowDefinitionResponse struct { + Available bool `json:"available"` + DefinitionSha string `json:"definition_sha"` + Id string `json:"id"` + Inputs *[]WorkflowInputResponse `json:"inputs"` + Name string `json:"name"` + Path string `json:"path"` + State string `json:"state"` + UnavailableReason *string `json:"unavailable_reason,omitempty"` + WebUrl string `json:"web_url"` +} + +// WorkflowDispatchBody defines model for WorkflowDispatchBody. +type WorkflowDispatchBody struct { + // Schema A URL to the JSON Schema for this object. + // + // Example: /api/v1/schemas/WorkflowDispatchBody.json + Schema *string `json:"$schema,omitempty"` + ExpectedDefinitionSha string `json:"expected_definition_sha"` + Inputs map[string]interface{} `json:"inputs"` + Ref string `json:"ref"` +} + +// WorkflowDispatchResponse defines model for WorkflowDispatchResponse. +type WorkflowDispatchResponse struct { + // Schema A URL to the JSON Schema for this object. + // + // Example: /api/v1/schemas/WorkflowDispatchResponse.json + Schema *string `json:"$schema,omitempty"` + Accepted bool `json:"accepted"` + LocatingRun bool `json:"locating_run"` + Run *WorkflowRunResponse `json:"run,omitempty"` +} + +// WorkflowEnvironmentResponse defines model for WorkflowEnvironmentResponse. +type WorkflowEnvironmentResponse struct { + Name string `json:"name"` +} + +// WorkflowInputResponse defines model for WorkflowInputResponse. +type WorkflowInputResponse struct { + Default interface{} `json:"default,omitempty"` + Description *string `json:"description,omitempty"` + HasDefault bool `json:"has_default"` + Name string `json:"name"` + Options *[]string `json:"options,omitempty"` + Required bool `json:"required"` + Type WorkflowInputResponseType `json:"type"` +} + +// WorkflowInputResponseType defines model for WorkflowInputResponse.Type. +type WorkflowInputResponseType string + +// WorkflowJobsResponse defines model for WorkflowJobsResponse. +type WorkflowJobsResponse struct { + // Schema A URL to the JSON Schema for this object. + // + // Example: /api/v1/schemas/WorkflowJobsResponse.json + Schema *string `json:"$schema,omitempty"` + Items *[]WorkflowRunJobResponse `json:"items"` + Repo RepoRefResponse `json:"repo"` +} + +// WorkflowRunJobResponse defines model for WorkflowRunJobResponse. +type WorkflowRunJobResponse struct { + CompletedAt *time.Time `json:"completed_at,omitempty"` + Conclusion string `json:"conclusion"` + Id string `json:"id"` + Name string `json:"name"` + StartedAt *time.Time `json:"started_at,omitempty"` + Status string `json:"status"` + Steps *[]WorkflowRunStepResponse `json:"steps"` + WebUrl *string `json:"web_url,omitempty"` +} + +// WorkflowRunResponse defines model for WorkflowRunResponse. +type WorkflowRunResponse struct { + Actor string `json:"actor"` + Conclusion string `json:"conclusion"` + CreatedAt *time.Time `json:"created_at,omitempty"` + Event string `json:"event"` + HeadSha string `json:"head_sha"` + Id string `json:"id"` + Name string `json:"name"` + Ref string `json:"ref"` + RunNumber int64 `json:"run_number"` + Status string `json:"status"` + UpdatedAt *time.Time `json:"updated_at,omitempty"` + WebUrl *string `json:"web_url,omitempty"` + WorkflowId string `json:"workflow_id"` +} + +// WorkflowRunStepResponse defines model for WorkflowRunStepResponse. +type WorkflowRunStepResponse struct { + CompletedAt *time.Time `json:"completed_at,omitempty"` + Conclusion string `json:"conclusion"` + Name string `json:"name"` + Number int64 `json:"number"` + StartedAt *time.Time `json:"started_at,omitempty"` + Status string `json:"status"` +} + +// WorkflowRunsResponse defines model for WorkflowRunsResponse. +type WorkflowRunsResponse struct { + // Schema A URL to the JSON Schema for this object. + // + // Example: /api/v1/schemas/WorkflowRunsResponse.json + Schema *string `json:"$schema,omitempty"` + Exhausted bool `json:"exhausted"` + Items *[]WorkflowRunResponse `json:"items"` + NextCursor *string `json:"next_cursor,omitempty"` + Repo RepoRefResponse `json:"repo"` +} + // WorkflowStateMetaResponse defines model for WorkflowStateMetaResponse. type WorkflowStateMetaResponse struct { Status WorkflowStateMetaResponseStatus `json:"status"` @@ -4852,6 +5030,15 @@ type WorktreeSummary struct { SyncBehind *int64 `json:"syncBehind,omitempty"` } +// ListWorkflowRunsParams defines parameters for ListWorkflowRuns. +type ListWorkflowRunsParams struct { + WorkflowId *string `form:"workflow_id,omitempty" json:"workflow_id,omitempty"` + Event *string `form:"event,omitempty" json:"event,omitempty"` + Branch *string `form:"branch,omitempty" json:"branch,omitempty"` + Cursor *string `form:"cursor,omitempty" json:"cursor,omitempty"` + PerPage *int64 `form:"per_page,omitempty" json:"per_page,omitempty"` +} + // ListActivityParams defines parameters for ListActivity. type ListActivityParams struct { // Repo Repository filter. Accepts provider|platform_host/repo_path, with comma-separated values for multiple repositories. @@ -5123,6 +5310,15 @@ type LaunchFleetWorkspaceRuntimeSessionJSONBody map[string]interface{} // RenameFleetWorkspaceRuntimeSessionJSONBody defines parameters for RenameFleetWorkspaceRuntimeSession. type RenameFleetWorkspaceRuntimeSessionJSONBody map[string]interface{} +// ListWorkflowRunsOnHostParams defines parameters for ListWorkflowRunsOnHost. +type ListWorkflowRunsOnHostParams struct { + WorkflowId *string `form:"workflow_id,omitempty" json:"workflow_id,omitempty"` + Event *string `form:"event,omitempty" json:"event,omitempty"` + Branch *string `form:"branch,omitempty" json:"branch,omitempty"` + Cursor *string `form:"cursor,omitempty" json:"cursor,omitempty"` + PerPage *int64 `form:"per_page,omitempty" json:"per_page,omitempty"` +} + // GetPullDiffOnHostParams defines parameters for GetPullDiffOnHost. type GetPullDiffOnHostParams struct { Whitespace *string `form:"whitespace,omitempty" json:"whitespace,omitempty"` @@ -5544,6 +5740,9 @@ type GetWorkspaceFilesParams struct { To *string `form:"to,omitempty" json:"to,omitempty"` } +// DispatchWorkflowJSONRequestBody defines body for DispatchWorkflow for application/json ContentType. +type DispatchWorkflowJSONRequestBody = WorkflowDispatchBody + // ReceiveAgentHookJSONRequestBody defines body for ReceiveAgentHook for application/json ContentType. type ReceiveAgentHookJSONRequestBody = HookEvent @@ -5613,6 +5812,9 @@ type LaunchFleetWorkspaceRuntimeSessionJSONRequestBody LaunchFleetWorkspaceRunti // RenameFleetWorkspaceRuntimeSessionJSONRequestBody defines body for RenameFleetWorkspaceRuntimeSession for application/json ContentType. type RenameFleetWorkspaceRuntimeSessionJSONRequestBody RenameFleetWorkspaceRuntimeSessionJSONBody +// DispatchWorkflowOnHostJSONRequestBody defines body for DispatchWorkflowOnHost for application/json ContentType. +type DispatchWorkflowOnHostJSONRequestBody = WorkflowDispatchBody + // CreateIssueOnHostJSONRequestBody defines body for CreateIssueOnHost for application/json ContentType. type CreateIssueOnHostJSONRequestBody = CreateIssueHostInputBody @@ -6121,6 +6323,35 @@ func WithRequestEditorFn(fn RequestEditorFn) ClientOption { // The interface specification for the client above. type ClientInterface interface { + // ListWorkflowRuns List workflow runs + // + // Corresponds with GET /actions/{provider}/{owner}/{name}/runs (the `ListWorkflowRuns` operationId). + ListWorkflowRuns(ctx context.Context, provider string, owner string, name string, params *ListWorkflowRunsParams, reqEditors ...RequestEditorFn) (*http.Response, error) + + // ListWorkflowRunJobs List workflow run jobs + // + // Corresponds with GET /actions/{provider}/{owner}/{name}/runs/{run_id}/jobs (the `ListWorkflowRunJobs` operationId). + ListWorkflowRunJobs(ctx context.Context, provider string, owner string, name string, runId string, reqEditors ...RequestEditorFn) (*http.Response, error) + + // ListWorkflows List manual workflows + // + // Corresponds with GET /actions/{provider}/{owner}/{name}/workflows (the `ListWorkflows` operationId). + ListWorkflows(ctx context.Context, provider string, owner string, name string, reqEditors ...RequestEditorFn) (*http.Response, error) + + // DispatchWorkflowWithBody Dispatch workflow + // + // Takes any type of body and a specified content type. + // + // Corresponds with POST /actions/{provider}/{owner}/{name}/workflows/{workflow_id}/dispatch (the `DispatchWorkflow` operationId). + DispatchWorkflowWithBody(ctx context.Context, provider string, owner string, name string, workflowId string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + // DispatchWorkflow Dispatch workflow + // + // Takes a body of the `application/json` content type. + // + // Corresponds with POST /actions/{provider}/{owner}/{name}/workflows/{workflow_id}/dispatch (the `DispatchWorkflow` operationId). + DispatchWorkflow(ctx context.Context, provider string, owner string, name string, workflowId string, body DispatchWorkflowJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + // ListActivity List activity // // Corresponds with GET /activity (the `ListActivity` operationId). @@ -6698,6 +6929,35 @@ type ClientInterface interface { // Corresponds with GET /fleet/hosts/{host_key}/workspaces/{id}/runtime/sessions/{session_key}/attach-spec (the `GetFleetWorkspaceRuntimeSessionAttachSpec` operationId). GetFleetWorkspaceRuntimeSessionAttachSpec(ctx context.Context, hostKey string, id string, sessionKey string, reqEditors ...RequestEditorFn) (*http.Response, error) + // ListWorkflowRunsOnHost List workflow runs + // + // Corresponds with GET /host/{platform_host}/actions/{provider}/{owner}/{name}/runs (the `ListWorkflowRunsOnHost` operationId). + ListWorkflowRunsOnHost(ctx context.Context, platformHost string, provider string, owner string, name string, params *ListWorkflowRunsOnHostParams, reqEditors ...RequestEditorFn) (*http.Response, error) + + // ListWorkflowRunJobsOnHost List workflow run jobs + // + // Corresponds with GET /host/{platform_host}/actions/{provider}/{owner}/{name}/runs/{run_id}/jobs (the `ListWorkflowRunJobsOnHost` operationId). + ListWorkflowRunJobsOnHost(ctx context.Context, platformHost string, provider string, owner string, name string, runId string, reqEditors ...RequestEditorFn) (*http.Response, error) + + // ListWorkflowsOnHost List manual workflows + // + // Corresponds with GET /host/{platform_host}/actions/{provider}/{owner}/{name}/workflows (the `ListWorkflowsOnHost` operationId). + ListWorkflowsOnHost(ctx context.Context, platformHost string, provider string, owner string, name string, reqEditors ...RequestEditorFn) (*http.Response, error) + + // DispatchWorkflowOnHostWithBody Dispatch workflow + // + // Takes any type of body and a specified content type. + // + // Corresponds with POST /host/{platform_host}/actions/{provider}/{owner}/{name}/workflows/{workflow_id}/dispatch (the `DispatchWorkflowOnHost` operationId). + DispatchWorkflowOnHostWithBody(ctx context.Context, platformHost string, provider string, owner string, name string, workflowId string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + // DispatchWorkflowOnHost Dispatch workflow + // + // Takes a body of the `application/json` content type. + // + // Corresponds with POST /host/{platform_host}/actions/{provider}/{owner}/{name}/workflows/{workflow_id}/dispatch (the `DispatchWorkflowOnHost` operationId). + DispatchWorkflowOnHost(ctx context.Context, platformHost string, provider string, owner string, name string, workflowId string, body DispatchWorkflowOnHostJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + // CreateIssueOnHostWithBody Create issue // // Takes any type of body and a specified content type. @@ -8749,6 +9009,85 @@ type ClientInterface interface { RemoveStaleWorktree(ctx context.Context, body RemoveStaleWorktreeJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) } +// ListWorkflowRuns List workflow runs +// +// Corresponds with GET /actions/{provider}/{owner}/{name}/runs (the `ListWorkflowRuns` operationId). +func (c *Client) ListWorkflowRuns(ctx context.Context, provider string, owner string, name string, params *ListWorkflowRunsParams, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewListWorkflowRunsRequest(c.Server, provider, owner, name, params) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +// ListWorkflowRunJobs List workflow run jobs +// +// Corresponds with GET /actions/{provider}/{owner}/{name}/runs/{run_id}/jobs (the `ListWorkflowRunJobs` operationId). +func (c *Client) ListWorkflowRunJobs(ctx context.Context, provider string, owner string, name string, runId string, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewListWorkflowRunJobsRequest(c.Server, provider, owner, name, runId) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +// ListWorkflows List manual workflows +// +// Corresponds with GET /actions/{provider}/{owner}/{name}/workflows (the `ListWorkflows` operationId). +func (c *Client) ListWorkflows(ctx context.Context, provider string, owner string, name string, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewListWorkflowsRequest(c.Server, provider, owner, name) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +// DispatchWorkflowWithBody Dispatch workflow +// +// Takes any type of body and a specified content type. +// +// Corresponds with POST /actions/{provider}/{owner}/{name}/workflows/{workflow_id}/dispatch (the `DispatchWorkflow` operationId). +func (c *Client) DispatchWorkflowWithBody(ctx context.Context, provider string, owner string, name string, workflowId string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewDispatchWorkflowRequestWithBody(c.Server, provider, owner, name, workflowId, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +// DispatchWorkflow Dispatch workflow +// +// Takes a body of the `application/json` content type. +// +// Corresponds with POST /actions/{provider}/{owner}/{name}/workflows/{workflow_id}/dispatch (the `DispatchWorkflow` operationId). +func (c *Client) DispatchWorkflow(ctx context.Context, provider string, owner string, name string, workflowId string, body DispatchWorkflowJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewDispatchWorkflowRequest(c.Server, provider, owner, name, workflowId, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + // ListActivity List activity // // Corresponds with GET /activity (the `ListActivity` operationId). @@ -10296,6 +10635,85 @@ func (c *Client) GetFleetWorkspaceRuntimeSessionAttachSpec(ctx context.Context, return c.Client.Do(req) } +// ListWorkflowRunsOnHost List workflow runs +// +// Corresponds with GET /host/{platform_host}/actions/{provider}/{owner}/{name}/runs (the `ListWorkflowRunsOnHost` operationId). +func (c *Client) ListWorkflowRunsOnHost(ctx context.Context, platformHost string, provider string, owner string, name string, params *ListWorkflowRunsOnHostParams, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewListWorkflowRunsOnHostRequest(c.Server, platformHost, provider, owner, name, params) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +// ListWorkflowRunJobsOnHost List workflow run jobs +// +// Corresponds with GET /host/{platform_host}/actions/{provider}/{owner}/{name}/runs/{run_id}/jobs (the `ListWorkflowRunJobsOnHost` operationId). +func (c *Client) ListWorkflowRunJobsOnHost(ctx context.Context, platformHost string, provider string, owner string, name string, runId string, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewListWorkflowRunJobsOnHostRequest(c.Server, platformHost, provider, owner, name, runId) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +// ListWorkflowsOnHost List manual workflows +// +// Corresponds with GET /host/{platform_host}/actions/{provider}/{owner}/{name}/workflows (the `ListWorkflowsOnHost` operationId). +func (c *Client) ListWorkflowsOnHost(ctx context.Context, platformHost string, provider string, owner string, name string, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewListWorkflowsOnHostRequest(c.Server, platformHost, provider, owner, name) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +// DispatchWorkflowOnHostWithBody Dispatch workflow +// +// Takes any type of body and a specified content type. +// +// Corresponds with POST /host/{platform_host}/actions/{provider}/{owner}/{name}/workflows/{workflow_id}/dispatch (the `DispatchWorkflowOnHost` operationId). +func (c *Client) DispatchWorkflowOnHostWithBody(ctx context.Context, platformHost string, provider string, owner string, name string, workflowId string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewDispatchWorkflowOnHostRequestWithBody(c.Server, platformHost, provider, owner, name, workflowId, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +// DispatchWorkflowOnHost Dispatch workflow +// +// Takes a body of the `application/json` content type. +// +// Corresponds with POST /host/{platform_host}/actions/{provider}/{owner}/{name}/workflows/{workflow_id}/dispatch (the `DispatchWorkflowOnHost` operationId). +func (c *Client) DispatchWorkflowOnHost(ctx context.Context, platformHost string, provider string, owner string, name string, workflowId string, body DispatchWorkflowOnHostJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewDispatchWorkflowOnHostRequest(c.Server, platformHost, provider, owner, name, workflowId, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + // CreateIssueOnHostWithBody Create issue // // Takes any type of body and a specified content type. @@ -15686,6 +16104,300 @@ func (c *Client) RemoveStaleWorktree(ctx context.Context, body RemoveStaleWorktr return c.Client.Do(req) } +// NewListWorkflowRunsRequest constructs an http.Request for the ListWorkflowRuns method +func NewListWorkflowRunsRequest(server string, provider string, owner string, name string, params *ListWorkflowRunsParams) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "provider", provider, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) + if err != nil { + return nil, err + } + + var pathParam1 string + + pathParam1, err = runtime.StyleParamWithOptions("simple", false, "owner", owner, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) + if err != nil { + return nil, err + } + + var pathParam2 string + + pathParam2, err = runtime.StyleParamWithOptions("simple", false, "name", name, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/actions/%s/%s/%s/runs", pathParam0, pathParam1, pathParam2) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + if params != nil { + // queryValues collects non-styled parameters (passthrough, JSON) + // that are safe to round-trip through url.Values.Encode(). + queryValues := queryURL.Query() + // rawQueryFragments collects pre-encoded query fragments from + // styled parameters, preserving literal commas as delimiters + // per the OpenAPI spec (e.g. "color=blue,black,brown"). + var rawQueryFragments []string + + if params.WorkflowId != nil { + + if queryFrag, err := runtime.StyleParamWithOptions("form", false, "workflow_id", *params.WorkflowId, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { + return nil, err + } else { + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) + } + } + + } + + if params.Event != nil { + + if queryFrag, err := runtime.StyleParamWithOptions("form", false, "event", *params.Event, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { + return nil, err + } else { + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) + } + } + + } + + if params.Branch != nil { + + if queryFrag, err := runtime.StyleParamWithOptions("form", false, "branch", *params.Branch, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { + return nil, err + } else { + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) + } + } + + } + + if params.Cursor != nil { + + if queryFrag, err := runtime.StyleParamWithOptions("form", false, "cursor", *params.Cursor, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { + return nil, err + } else { + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) + } + } + + } + + if params.PerPage != nil { + + if queryFrag, err := runtime.StyleParamWithOptions("form", false, "per_page", *params.PerPage, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "integer", Format: "int64"}); err != nil { + return nil, err + } else { + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) + } + } + + } + + if encoded := queryValues.Encode(); encoded != "" { + rawQueryFragments = append(rawQueryFragments, encoded) + } + queryURL.RawQuery = strings.Join(rawQueryFragments, "&") + } + + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewListWorkflowRunJobsRequest constructs an http.Request for the ListWorkflowRunJobs method +func NewListWorkflowRunJobsRequest(server string, provider string, owner string, name string, runId string) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "provider", provider, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) + if err != nil { + return nil, err + } + + var pathParam1 string + + pathParam1, err = runtime.StyleParamWithOptions("simple", false, "owner", owner, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) + if err != nil { + return nil, err + } + + var pathParam2 string + + pathParam2, err = runtime.StyleParamWithOptions("simple", false, "name", name, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) + if err != nil { + return nil, err + } + + var pathParam3 string + + pathParam3, err = runtime.StyleParamWithOptions("simple", false, "run_id", runId, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/actions/%s/%s/%s/runs/%s/jobs", pathParam0, pathParam1, pathParam2, pathParam3) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewListWorkflowsRequest constructs an http.Request for the ListWorkflows method +func NewListWorkflowsRequest(server string, provider string, owner string, name string) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "provider", provider, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) + if err != nil { + return nil, err + } + + var pathParam1 string + + pathParam1, err = runtime.StyleParamWithOptions("simple", false, "owner", owner, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) + if err != nil { + return nil, err + } + + var pathParam2 string + + pathParam2, err = runtime.StyleParamWithOptions("simple", false, "name", name, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/actions/%s/%s/%s/workflows", pathParam0, pathParam1, pathParam2) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewDispatchWorkflowRequest calls the generic DispatchWorkflow builder with application/json body +func NewDispatchWorkflowRequest(server string, provider string, owner string, name string, workflowId string, body DispatchWorkflowJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewDispatchWorkflowRequestWithBody(server, provider, owner, name, workflowId, "application/json", bodyReader) +} + +// NewDispatchWorkflowRequestWithBody constructs an http.Request for the DispatchWorkflow method, with any body, and a specified content type +func NewDispatchWorkflowRequestWithBody(server string, provider string, owner string, name string, workflowId string, contentType string, body io.Reader) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "provider", provider, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) + if err != nil { + return nil, err + } + + var pathParam1 string + + pathParam1, err = runtime.StyleParamWithOptions("simple", false, "owner", owner, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) + if err != nil { + return nil, err + } + + var pathParam2 string + + pathParam2, err = runtime.StyleParamWithOptions("simple", false, "name", name, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) + if err != nil { + return nil, err + } + + var pathParam3 string + + pathParam3, err = runtime.StyleParamWithOptions("simple", false, "workflow_id", workflowId, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/actions/%s/%s/%s/workflows/%s/dispatch", pathParam0, pathParam1, pathParam2, pathParam3) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + // NewListActivityRequest constructs an http.Request for the ListActivity method func NewListActivityRequest(server string, params *ListActivityParams) (*http.Request, error) { var err error @@ -19882,7 +20594,116 @@ func NewStopFleetWorkspaceRuntimeSessionRequest(server string, hostKey string, i return nil, err } - operationPath := fmt.Sprintf("/fleet/hosts/%s/workspaces/%s/runtime/sessions/%s", pathParam0, pathParam1, pathParam2) + operationPath := fmt.Sprintf("/fleet/hosts/%s/workspaces/%s/runtime/sessions/%s", pathParam0, pathParam1, pathParam2) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodDelete, queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewRenameFleetWorkspaceRuntimeSessionRequest calls the generic RenameFleetWorkspaceRuntimeSession builder with application/json body +func NewRenameFleetWorkspaceRuntimeSessionRequest(server string, hostKey string, id string, sessionKey string, body RenameFleetWorkspaceRuntimeSessionJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewRenameFleetWorkspaceRuntimeSessionRequestWithBody(server, hostKey, id, sessionKey, "application/json", bodyReader) +} + +// NewRenameFleetWorkspaceRuntimeSessionRequestWithBody constructs an http.Request for the RenameFleetWorkspaceRuntimeSession method, with any body, and a specified content type +func NewRenameFleetWorkspaceRuntimeSessionRequestWithBody(server string, hostKey string, id string, sessionKey string, contentType string, body io.Reader) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "host_key", hostKey, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) + if err != nil { + return nil, err + } + + var pathParam1 string + + pathParam1, err = runtime.StyleParamWithOptions("simple", false, "id", id, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) + if err != nil { + return nil, err + } + + var pathParam2 string + + pathParam2, err = runtime.StyleParamWithOptions("simple", false, "session_key", sessionKey, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/fleet/hosts/%s/workspaces/%s/runtime/sessions/%s", pathParam0, pathParam1, pathParam2) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPatch, queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + +// NewGetFleetWorkspaceRuntimeSessionAttachSpecRequest constructs an http.Request for the GetFleetWorkspaceRuntimeSessionAttachSpec method +func NewGetFleetWorkspaceRuntimeSessionAttachSpecRequest(server string, hostKey string, id string, sessionKey string) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "host_key", hostKey, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) + if err != nil { + return nil, err + } + + var pathParam1 string + + pathParam1, err = runtime.StyleParamWithOptions("simple", false, "id", id, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) + if err != nil { + return nil, err + } + + var pathParam2 string + + pathParam2, err = runtime.StyleParamWithOptions("simple", false, "session_key", sessionKey, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/fleet/hosts/%s/workspaces/%s/runtime/sessions/%s/attach-spec", pathParam0, pathParam1, pathParam2) if operationPath[0] == '/' { operationPath = "." + operationPath } @@ -19892,7 +20713,7 @@ func NewStopFleetWorkspaceRuntimeSessionRequest(server string, hostKey string, i return nil, err } - req, err := http.NewRequest(http.MethodDelete, queryURL.String(), nil) + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) if err != nil { return nil, err } @@ -19900,38 +20721,171 @@ func NewStopFleetWorkspaceRuntimeSessionRequest(server string, hostKey string, i return req, nil } -// NewRenameFleetWorkspaceRuntimeSessionRequest calls the generic RenameFleetWorkspaceRuntimeSession builder with application/json body -func NewRenameFleetWorkspaceRuntimeSessionRequest(server string, hostKey string, id string, sessionKey string, body RenameFleetWorkspaceRuntimeSessionJSONRequestBody) (*http.Request, error) { - var bodyReader io.Reader - buf, err := json.Marshal(body) +// NewListWorkflowRunsOnHostRequest constructs an http.Request for the ListWorkflowRunsOnHost method +func NewListWorkflowRunsOnHostRequest(server string, platformHost string, provider string, owner string, name string, params *ListWorkflowRunsOnHostParams) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "platform_host", platformHost, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } - bodyReader = bytes.NewReader(buf) - return NewRenameFleetWorkspaceRuntimeSessionRequestWithBody(server, hostKey, id, sessionKey, "application/json", bodyReader) + + var pathParam1 string + + pathParam1, err = runtime.StyleParamWithOptions("simple", false, "provider", provider, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) + if err != nil { + return nil, err + } + + var pathParam2 string + + pathParam2, err = runtime.StyleParamWithOptions("simple", false, "owner", owner, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) + if err != nil { + return nil, err + } + + var pathParam3 string + + pathParam3, err = runtime.StyleParamWithOptions("simple", false, "name", name, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/host/%s/actions/%s/%s/%s/runs", pathParam0, pathParam1, pathParam2, pathParam3) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + if params != nil { + // queryValues collects non-styled parameters (passthrough, JSON) + // that are safe to round-trip through url.Values.Encode(). + queryValues := queryURL.Query() + // rawQueryFragments collects pre-encoded query fragments from + // styled parameters, preserving literal commas as delimiters + // per the OpenAPI spec (e.g. "color=blue,black,brown"). + var rawQueryFragments []string + + if params.WorkflowId != nil { + + if queryFrag, err := runtime.StyleParamWithOptions("form", false, "workflow_id", *params.WorkflowId, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { + return nil, err + } else { + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) + } + } + + } + + if params.Event != nil { + + if queryFrag, err := runtime.StyleParamWithOptions("form", false, "event", *params.Event, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { + return nil, err + } else { + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) + } + } + + } + + if params.Branch != nil { + + if queryFrag, err := runtime.StyleParamWithOptions("form", false, "branch", *params.Branch, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { + return nil, err + } else { + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) + } + } + + } + + if params.Cursor != nil { + + if queryFrag, err := runtime.StyleParamWithOptions("form", false, "cursor", *params.Cursor, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { + return nil, err + } else { + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) + } + } + + } + + if params.PerPage != nil { + + if queryFrag, err := runtime.StyleParamWithOptions("form", false, "per_page", *params.PerPage, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "integer", Format: "int64"}); err != nil { + return nil, err + } else { + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) + } + } + + } + + if encoded := queryValues.Encode(); encoded != "" { + rawQueryFragments = append(rawQueryFragments, encoded) + } + queryURL.RawQuery = strings.Join(rawQueryFragments, "&") + } + + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil } -// NewRenameFleetWorkspaceRuntimeSessionRequestWithBody constructs an http.Request for the RenameFleetWorkspaceRuntimeSession method, with any body, and a specified content type -func NewRenameFleetWorkspaceRuntimeSessionRequestWithBody(server string, hostKey string, id string, sessionKey string, contentType string, body io.Reader) (*http.Request, error) { +// NewListWorkflowRunJobsOnHostRequest constructs an http.Request for the ListWorkflowRunJobsOnHost method +func NewListWorkflowRunJobsOnHostRequest(server string, platformHost string, provider string, owner string, name string, runId string) (*http.Request, error) { var err error var pathParam0 string - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "host_key", hostKey, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "platform_host", platformHost, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } var pathParam1 string - pathParam1, err = runtime.StyleParamWithOptions("simple", false, "id", id, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) + pathParam1, err = runtime.StyleParamWithOptions("simple", false, "provider", provider, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } var pathParam2 string - pathParam2, err = runtime.StyleParamWithOptions("simple", false, "session_key", sessionKey, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) + pathParam2, err = runtime.StyleParamWithOptions("simple", false, "owner", owner, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) + if err != nil { + return nil, err + } + + var pathParam3 string + + pathParam3, err = runtime.StyleParamWithOptions("simple", false, "name", name, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) + if err != nil { + return nil, err + } + + var pathParam4 string + + pathParam4, err = runtime.StyleParamWithOptions("simple", false, "run_id", runId, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -19941,7 +20895,7 @@ func NewRenameFleetWorkspaceRuntimeSessionRequestWithBody(server string, hostKey return nil, err } - operationPath := fmt.Sprintf("/fleet/hosts/%s/workspaces/%s/runtime/sessions/%s", pathParam0, pathParam1, pathParam2) + operationPath := fmt.Sprintf("/host/%s/actions/%s/%s/%s/runs/%s/jobs", pathParam0, pathParam1, pathParam2, pathParam3, pathParam4) if operationPath[0] == '/' { operationPath = "." + operationPath } @@ -19951,37 +20905,42 @@ func NewRenameFleetWorkspaceRuntimeSessionRequestWithBody(server string, hostKey return nil, err } - req, err := http.NewRequest(http.MethodPatch, queryURL.String(), body) + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) if err != nil { return nil, err } - req.Header.Add("Content-Type", contentType) - return req, nil } -// NewGetFleetWorkspaceRuntimeSessionAttachSpecRequest constructs an http.Request for the GetFleetWorkspaceRuntimeSessionAttachSpec method -func NewGetFleetWorkspaceRuntimeSessionAttachSpecRequest(server string, hostKey string, id string, sessionKey string) (*http.Request, error) { +// NewListWorkflowsOnHostRequest constructs an http.Request for the ListWorkflowsOnHost method +func NewListWorkflowsOnHostRequest(server string, platformHost string, provider string, owner string, name string) (*http.Request, error) { var err error var pathParam0 string - pathParam0, err = runtime.StyleParamWithOptions("simple", false, "host_key", hostKey, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "platform_host", platformHost, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } var pathParam1 string - pathParam1, err = runtime.StyleParamWithOptions("simple", false, "id", id, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) + pathParam1, err = runtime.StyleParamWithOptions("simple", false, "provider", provider, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } var pathParam2 string - pathParam2, err = runtime.StyleParamWithOptions("simple", false, "session_key", sessionKey, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) + pathParam2, err = runtime.StyleParamWithOptions("simple", false, "owner", owner, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) + if err != nil { + return nil, err + } + + var pathParam3 string + + pathParam3, err = runtime.StyleParamWithOptions("simple", false, "name", name, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) if err != nil { return nil, err } @@ -19991,7 +20950,7 @@ func NewGetFleetWorkspaceRuntimeSessionAttachSpecRequest(server string, hostKey return nil, err } - operationPath := fmt.Sprintf("/fleet/hosts/%s/workspaces/%s/runtime/sessions/%s/attach-spec", pathParam0, pathParam1, pathParam2) + operationPath := fmt.Sprintf("/host/%s/actions/%s/%s/%s/workflows", pathParam0, pathParam1, pathParam2, pathParam3) if operationPath[0] == '/' { operationPath = "." + operationPath } @@ -20009,6 +20968,81 @@ func NewGetFleetWorkspaceRuntimeSessionAttachSpecRequest(server string, hostKey return req, nil } +// NewDispatchWorkflowOnHostRequest calls the generic DispatchWorkflowOnHost builder with application/json body +func NewDispatchWorkflowOnHostRequest(server string, platformHost string, provider string, owner string, name string, workflowId string, body DispatchWorkflowOnHostJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewDispatchWorkflowOnHostRequestWithBody(server, platformHost, provider, owner, name, workflowId, "application/json", bodyReader) +} + +// NewDispatchWorkflowOnHostRequestWithBody constructs an http.Request for the DispatchWorkflowOnHost method, with any body, and a specified content type +func NewDispatchWorkflowOnHostRequestWithBody(server string, platformHost string, provider string, owner string, name string, workflowId string, contentType string, body io.Reader) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "platform_host", platformHost, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) + if err != nil { + return nil, err + } + + var pathParam1 string + + pathParam1, err = runtime.StyleParamWithOptions("simple", false, "provider", provider, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) + if err != nil { + return nil, err + } + + var pathParam2 string + + pathParam2, err = runtime.StyleParamWithOptions("simple", false, "owner", owner, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) + if err != nil { + return nil, err + } + + var pathParam3 string + + pathParam3, err = runtime.StyleParamWithOptions("simple", false, "name", name, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) + if err != nil { + return nil, err + } + + var pathParam4 string + + pathParam4, err = runtime.StyleParamWithOptions("simple", false, "workflow_id", workflowId, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/host/%s/actions/%s/%s/%s/workflows/%s/dispatch", pathParam0, pathParam1, pathParam2, pathParam3, pathParam4) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + // NewCreateIssueOnHostRequest calls the generic CreateIssueOnHost builder with application/json body func NewCreateIssueOnHostRequest(server string, platformHost string, provider string, owner string, name string, body CreateIssueOnHostJSONRequestBody) (*http.Request, error) { var bodyReader io.Reader @@ -35159,6 +36193,20 @@ func WithBaseURL(baseURL string) ClientOption { // ClientWithResponsesInterface is the interface specification for the client with responses above. type ClientWithResponsesInterface interface { + // ListWorkflowRunsWithResponse request + ListWorkflowRunsWithResponse(ctx context.Context, provider string, owner string, name string, params *ListWorkflowRunsParams, reqEditors ...RequestEditorFn) (*ListWorkflowRunsResponse, error) + + // ListWorkflowRunJobsWithResponse request + ListWorkflowRunJobsWithResponse(ctx context.Context, provider string, owner string, name string, runId string, reqEditors ...RequestEditorFn) (*ListWorkflowRunJobsResponse, error) + + // ListWorkflowsWithResponse request + ListWorkflowsWithResponse(ctx context.Context, provider string, owner string, name string, reqEditors ...RequestEditorFn) (*ListWorkflowsResponse, error) + + // DispatchWorkflowWithBodyWithResponse request with any body + DispatchWorkflowWithBodyWithResponse(ctx context.Context, provider string, owner string, name string, workflowId string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*DispatchWorkflowResponse, error) + + DispatchWorkflowWithResponse(ctx context.Context, provider string, owner string, name string, workflowId string, body DispatchWorkflowJSONRequestBody, reqEditors ...RequestEditorFn) (*DispatchWorkflowResponse, error) + // ListActivityWithResponse request ListActivityWithResponse(ctx context.Context, params *ListActivityParams, reqEditors ...RequestEditorFn) (*ListActivityResponse, error) @@ -35424,6 +36472,20 @@ type ClientWithResponsesInterface interface { // GetFleetWorkspaceRuntimeSessionAttachSpecWithResponse request GetFleetWorkspaceRuntimeSessionAttachSpecWithResponse(ctx context.Context, hostKey string, id string, sessionKey string, reqEditors ...RequestEditorFn) (*GetFleetWorkspaceRuntimeSessionAttachSpecResponse, error) + // ListWorkflowRunsOnHostWithResponse request + ListWorkflowRunsOnHostWithResponse(ctx context.Context, platformHost string, provider string, owner string, name string, params *ListWorkflowRunsOnHostParams, reqEditors ...RequestEditorFn) (*ListWorkflowRunsOnHostResponse, error) + + // ListWorkflowRunJobsOnHostWithResponse request + ListWorkflowRunJobsOnHostWithResponse(ctx context.Context, platformHost string, provider string, owner string, name string, runId string, reqEditors ...RequestEditorFn) (*ListWorkflowRunJobsOnHostResponse, error) + + // ListWorkflowsOnHostWithResponse request + ListWorkflowsOnHostWithResponse(ctx context.Context, platformHost string, provider string, owner string, name string, reqEditors ...RequestEditorFn) (*ListWorkflowsOnHostResponse, error) + + // DispatchWorkflowOnHostWithBodyWithResponse request with any body + DispatchWorkflowOnHostWithBodyWithResponse(ctx context.Context, platformHost string, provider string, owner string, name string, workflowId string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*DispatchWorkflowOnHostResponse, error) + + DispatchWorkflowOnHostWithResponse(ctx context.Context, platformHost string, provider string, owner string, name string, workflowId string, body DispatchWorkflowOnHostJSONRequestBody, reqEditors ...RequestEditorFn) (*DispatchWorkflowOnHostResponse, error) + // CreateIssueOnHostWithBodyWithResponse request with any body CreateIssueOnHostWithBodyWithResponse(ctx context.Context, platformHost string, provider string, owner string, name string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*CreateIssueOnHostResponse, error) @@ -36333,6 +37395,98 @@ type ClientWithResponsesInterface interface { RemoveStaleWorktreeWithResponse(ctx context.Context, body RemoveStaleWorktreeJSONRequestBody, reqEditors ...RequestEditorFn) (*RemoveStaleWorktreeResponse, error) } +type ListWorkflowRunsResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *WorkflowRunsResponse + ApplicationproblemJSONDefault *ProblemError +} + +// Status returns HTTPResponse.Status +func (r ListWorkflowRunsResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r ListWorkflowRunsResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +type ListWorkflowRunJobsResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *WorkflowJobsResponse + ApplicationproblemJSONDefault *ProblemError +} + +// Status returns HTTPResponse.Status +func (r ListWorkflowRunJobsResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r ListWorkflowRunJobsResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +type ListWorkflowsResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *WorkflowCatalogResponse + ApplicationproblemJSONDefault *ProblemError +} + +// Status returns HTTPResponse.Status +func (r ListWorkflowsResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r ListWorkflowsResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +type DispatchWorkflowResponse struct { + Body []byte + HTTPResponse *http.Response + JSON202 *WorkflowDispatchResponse + ApplicationproblemJSONDefault *ProblemError +} + +// Status returns HTTPResponse.Status +func (r DispatchWorkflowResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r DispatchWorkflowResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + type ListActivityResponse struct { Body []byte HTTPResponse *http.Response @@ -38009,6 +39163,98 @@ func (r GetFleetWorkspaceRuntimeSessionAttachSpecResponse) StatusCode() int { return 0 } +type ListWorkflowRunsOnHostResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *WorkflowRunsResponse + ApplicationproblemJSONDefault *ProblemError +} + +// Status returns HTTPResponse.Status +func (r ListWorkflowRunsOnHostResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r ListWorkflowRunsOnHostResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +type ListWorkflowRunJobsOnHostResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *WorkflowJobsResponse + ApplicationproblemJSONDefault *ProblemError +} + +// Status returns HTTPResponse.Status +func (r ListWorkflowRunJobsOnHostResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r ListWorkflowRunJobsOnHostResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +type ListWorkflowsOnHostResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *WorkflowCatalogResponse + ApplicationproblemJSONDefault *ProblemError +} + +// Status returns HTTPResponse.Status +func (r ListWorkflowsOnHostResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r ListWorkflowsOnHostResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +type DispatchWorkflowOnHostResponse struct { + Body []byte + HTTPResponse *http.Response + JSON202 *WorkflowDispatchResponse + ApplicationproblemJSONDefault *ProblemError +} + +// Status returns HTTPResponse.Status +func (r DispatchWorkflowOnHostResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r DispatchWorkflowOnHostResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + type CreateIssueOnHostResponse struct { Body []byte HTTPResponse *http.Response @@ -43485,6 +44731,50 @@ func (r RemoveStaleWorktreeResponse) StatusCode() int { return 0 } +// ListWorkflowRunsWithResponse request returning *ListWorkflowRunsResponse +func (c *ClientWithResponses) ListWorkflowRunsWithResponse(ctx context.Context, provider string, owner string, name string, params *ListWorkflowRunsParams, reqEditors ...RequestEditorFn) (*ListWorkflowRunsResponse, error) { + rsp, err := c.ListWorkflowRuns(ctx, provider, owner, name, params, reqEditors...) + if err != nil { + return nil, err + } + return ParseListWorkflowRunsResponse(rsp) +} + +// ListWorkflowRunJobsWithResponse request returning *ListWorkflowRunJobsResponse +func (c *ClientWithResponses) ListWorkflowRunJobsWithResponse(ctx context.Context, provider string, owner string, name string, runId string, reqEditors ...RequestEditorFn) (*ListWorkflowRunJobsResponse, error) { + rsp, err := c.ListWorkflowRunJobs(ctx, provider, owner, name, runId, reqEditors...) + if err != nil { + return nil, err + } + return ParseListWorkflowRunJobsResponse(rsp) +} + +// ListWorkflowsWithResponse request returning *ListWorkflowsResponse +func (c *ClientWithResponses) ListWorkflowsWithResponse(ctx context.Context, provider string, owner string, name string, reqEditors ...RequestEditorFn) (*ListWorkflowsResponse, error) { + rsp, err := c.ListWorkflows(ctx, provider, owner, name, reqEditors...) + if err != nil { + return nil, err + } + return ParseListWorkflowsResponse(rsp) +} + +// DispatchWorkflowWithBodyWithResponse request with arbitrary body returning *DispatchWorkflowResponse +func (c *ClientWithResponses) DispatchWorkflowWithBodyWithResponse(ctx context.Context, provider string, owner string, name string, workflowId string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*DispatchWorkflowResponse, error) { + rsp, err := c.DispatchWorkflowWithBody(ctx, provider, owner, name, workflowId, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseDispatchWorkflowResponse(rsp) +} + +func (c *ClientWithResponses) DispatchWorkflowWithResponse(ctx context.Context, provider string, owner string, name string, workflowId string, body DispatchWorkflowJSONRequestBody, reqEditors ...RequestEditorFn) (*DispatchWorkflowResponse, error) { + rsp, err := c.DispatchWorkflow(ctx, provider, owner, name, workflowId, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseDispatchWorkflowResponse(rsp) +} + // ListActivityWithResponse request returning *ListActivityResponse func (c *ClientWithResponses) ListActivityWithResponse(ctx context.Context, params *ListActivityParams, reqEditors ...RequestEditorFn) (*ListActivityResponse, error) { rsp, err := c.ListActivity(ctx, params, reqEditors...) @@ -44326,6 +45616,50 @@ func (c *ClientWithResponses) GetFleetWorkspaceRuntimeSessionAttachSpecWithRespo return ParseGetFleetWorkspaceRuntimeSessionAttachSpecResponse(rsp) } +// ListWorkflowRunsOnHostWithResponse request returning *ListWorkflowRunsOnHostResponse +func (c *ClientWithResponses) ListWorkflowRunsOnHostWithResponse(ctx context.Context, platformHost string, provider string, owner string, name string, params *ListWorkflowRunsOnHostParams, reqEditors ...RequestEditorFn) (*ListWorkflowRunsOnHostResponse, error) { + rsp, err := c.ListWorkflowRunsOnHost(ctx, platformHost, provider, owner, name, params, reqEditors...) + if err != nil { + return nil, err + } + return ParseListWorkflowRunsOnHostResponse(rsp) +} + +// ListWorkflowRunJobsOnHostWithResponse request returning *ListWorkflowRunJobsOnHostResponse +func (c *ClientWithResponses) ListWorkflowRunJobsOnHostWithResponse(ctx context.Context, platformHost string, provider string, owner string, name string, runId string, reqEditors ...RequestEditorFn) (*ListWorkflowRunJobsOnHostResponse, error) { + rsp, err := c.ListWorkflowRunJobsOnHost(ctx, platformHost, provider, owner, name, runId, reqEditors...) + if err != nil { + return nil, err + } + return ParseListWorkflowRunJobsOnHostResponse(rsp) +} + +// ListWorkflowsOnHostWithResponse request returning *ListWorkflowsOnHostResponse +func (c *ClientWithResponses) ListWorkflowsOnHostWithResponse(ctx context.Context, platformHost string, provider string, owner string, name string, reqEditors ...RequestEditorFn) (*ListWorkflowsOnHostResponse, error) { + rsp, err := c.ListWorkflowsOnHost(ctx, platformHost, provider, owner, name, reqEditors...) + if err != nil { + return nil, err + } + return ParseListWorkflowsOnHostResponse(rsp) +} + +// DispatchWorkflowOnHostWithBodyWithResponse request with arbitrary body returning *DispatchWorkflowOnHostResponse +func (c *ClientWithResponses) DispatchWorkflowOnHostWithBodyWithResponse(ctx context.Context, platformHost string, provider string, owner string, name string, workflowId string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*DispatchWorkflowOnHostResponse, error) { + rsp, err := c.DispatchWorkflowOnHostWithBody(ctx, platformHost, provider, owner, name, workflowId, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseDispatchWorkflowOnHostResponse(rsp) +} + +func (c *ClientWithResponses) DispatchWorkflowOnHostWithResponse(ctx context.Context, platformHost string, provider string, owner string, name string, workflowId string, body DispatchWorkflowOnHostJSONRequestBody, reqEditors ...RequestEditorFn) (*DispatchWorkflowOnHostResponse, error) { + rsp, err := c.DispatchWorkflowOnHost(ctx, platformHost, provider, owner, name, workflowId, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseDispatchWorkflowOnHostResponse(rsp) +} + // CreateIssueOnHostWithBodyWithResponse request with arbitrary body returning *CreateIssueOnHostResponse func (c *ClientWithResponses) CreateIssueOnHostWithBodyWithResponse(ctx context.Context, platformHost string, provider string, owner string, name string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*CreateIssueOnHostResponse, error) { rsp, err := c.CreateIssueOnHostWithBody(ctx, platformHost, provider, owner, name, contentType, body, reqEditors...) @@ -47238,6 +48572,138 @@ func (c *ClientWithResponses) RemoveStaleWorktreeWithResponse(ctx context.Contex return ParseRemoveStaleWorktreeResponse(rsp) } +// ParseListWorkflowRunsResponse parses an HTTP response from a ListWorkflowRunsWithResponse call +func ParseListWorkflowRunsResponse(rsp *http.Response) (*ListWorkflowRunsResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &ListWorkflowRunsResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest WorkflowRunsResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + var dest ProblemError + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSONDefault = &dest + + } + + return response, nil +} + +// ParseListWorkflowRunJobsResponse parses an HTTP response from a ListWorkflowRunJobsWithResponse call +func ParseListWorkflowRunJobsResponse(rsp *http.Response) (*ListWorkflowRunJobsResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &ListWorkflowRunJobsResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest WorkflowJobsResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + var dest ProblemError + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSONDefault = &dest + + } + + return response, nil +} + +// ParseListWorkflowsResponse parses an HTTP response from a ListWorkflowsWithResponse call +func ParseListWorkflowsResponse(rsp *http.Response) (*ListWorkflowsResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &ListWorkflowsResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest WorkflowCatalogResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + var dest ProblemError + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSONDefault = &dest + + } + + return response, nil +} + +// ParseDispatchWorkflowResponse parses an HTTP response from a DispatchWorkflowWithResponse call +func ParseDispatchWorkflowResponse(rsp *http.Response) (*DispatchWorkflowResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &DispatchWorkflowResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 202: + var dest WorkflowDispatchResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON202 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + var dest ProblemError + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSONDefault = &dest + + } + + return response, nil +} + // ParseListActivityResponse parses an HTTP response from a ListActivityWithResponse call func ParseListActivityResponse(rsp *http.Response) (*ListActivityResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) @@ -49632,6 +51098,138 @@ func ParseGetFleetWorkspaceRuntimeSessionAttachSpecResponse(rsp *http.Response) return response, nil } +// ParseListWorkflowRunsOnHostResponse parses an HTTP response from a ListWorkflowRunsOnHostWithResponse call +func ParseListWorkflowRunsOnHostResponse(rsp *http.Response) (*ListWorkflowRunsOnHostResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &ListWorkflowRunsOnHostResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest WorkflowRunsResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + var dest ProblemError + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSONDefault = &dest + + } + + return response, nil +} + +// ParseListWorkflowRunJobsOnHostResponse parses an HTTP response from a ListWorkflowRunJobsOnHostWithResponse call +func ParseListWorkflowRunJobsOnHostResponse(rsp *http.Response) (*ListWorkflowRunJobsOnHostResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &ListWorkflowRunJobsOnHostResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest WorkflowJobsResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + var dest ProblemError + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSONDefault = &dest + + } + + return response, nil +} + +// ParseListWorkflowsOnHostResponse parses an HTTP response from a ListWorkflowsOnHostWithResponse call +func ParseListWorkflowsOnHostResponse(rsp *http.Response) (*ListWorkflowsOnHostResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &ListWorkflowsOnHostResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest WorkflowCatalogResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + var dest ProblemError + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSONDefault = &dest + + } + + return response, nil +} + +// ParseDispatchWorkflowOnHostResponse parses an HTTP response from a DispatchWorkflowOnHostWithResponse call +func ParseDispatchWorkflowOnHostResponse(rsp *http.Response) (*DispatchWorkflowOnHostResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &DispatchWorkflowOnHostResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 202: + var dest WorkflowDispatchResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON202 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + var dest ProblemError + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSONDefault = &dest + + } + + return response, nil +} + // ParseCreateIssueOnHostResponse parses an HTTP response from a CreateIssueOnHostWithResponse call func ParseCreateIssueOnHostResponse(rsp *http.Response) (*CreateIssueOnHostResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) diff --git a/internal/server/api_test.go b/internal/server/api_test.go index db092c348a..bcfb154579 100644 --- a/internal/server/api_test.go +++ b/internal/server/api_test.go @@ -33110,3 +33110,42 @@ func TestMergeBlocksPredecessorWhenNativeStackRefreshIsPartial(t *testing.T) { assert.Contains(string(mergeResp.Body), `"blocking_number":100`) assert.False(merged, "the provider must not be asked to merge past an open predecessor") } + +func TestAPIHeadRepoKindClassifiesSameRepoForkAndUnknown(t *testing.T) { + require := require.New(t) + srv, database := setupTestServer(t) + repoID, err := database.UpsertRepo( + t.Context(), + verifiedGitHubRepoIdentity("github.com", "acme", "widget"), + ) + require.NoError(err) + now := time.Now().UTC().Truncate(time.Second) + for index, test := range []struct { + cloneURL string + want string + }{ + {cloneURL: "https://github.com/acme/widget.git", want: "same_repo"}, + {cloneURL: "https://github.com/contributor/widget.git", want: "fork"}, + {cloneURL: "", want: "unknown"}, + } { + number := 900 + index + _, err := database.UpsertMergeRequest(t.Context(), &db.MergeRequest{ + RepoID: repoID, PlatformID: int64(number), Number: number, + URL: "https://github.com/acme/widget/pull/" + strconv.Itoa(number), + Title: "Head repository classification", Author: "alice", State: "open", + HeadBranch: "feature/head-repo", BaseBranch: "main", + HeadRepoCloneURL: test.cloneURL, + CreatedAt: now, UpdatedAt: now, LastActivityAt: now, + }) + require.NoError(err) + response := doJSON( + t, srv, http.MethodGet, + "/api/v1/pulls/gh/acme/widget/"+strconv.Itoa(number), + nil, + ) + require.Equal(http.StatusOK, response.Code, response.Body.String()) + var detail pullapi.MergeRequestDetailResponse + require.NoError(json.Unmarshal(response.Body.Bytes(), &detail)) + assert.Equal(t, test.want, string(detail.HeadRepoKind)) + } +} diff --git a/internal/server/huma_routes.go b/internal/server/huma_routes.go index 434fa5007a..64cd284513 100644 --- a/internal/server/huma_routes.go +++ b/internal/server/huma_routes.go @@ -275,6 +275,7 @@ func (s *Server) registerAPI(api huma.API) { s.pullAPI.Register(api) s.issueAPI.Register(api) s.registerProviderRepoAPI(api) + s.workflowAPI.Register(api) s.repoBrowserAPI.Register(api) s.fleetAPI.Register(api) diff --git a/internal/server/pullapi/routes.go b/internal/server/pullapi/routes.go index 4edd310b3a..45e04bf392 100644 --- a/internal/server/pullapi/routes.go +++ b/internal/server/pullapi/routes.go @@ -24,6 +24,7 @@ import ( "go.kenn.io/forge/internal/platform/gitealike" "go.kenn.io/forge/internal/server/httpapi" "go.kenn.io/forge/internal/server/workspaceapi" + "go.kenn.io/forge/internal/workspace" ) var discussionIDPattern = regexp.MustCompile(`^[a-f0-9]{40}$`) @@ -563,6 +564,7 @@ func (s *Handler) buildPullDetailResponse( RepoOwner: repo.Owner, RepoName: repo.Name, PlatformHost: repo.PlatformHost, + HeadRepoKind: classifyHeadRepoKind(*repo, *mr), PlatformHeadSHA: mr.PlatformHeadSHA, PlatformBaseSHA: mr.PlatformBaseSHA, ReviewedHeadSHA: verifiedReviewedHeadSHA(mr), @@ -626,6 +628,23 @@ func (s *Handler) buildPullDetailResponse( return resp, nil } +func classifyHeadRepoKind(repo db.Repo, mr db.MergeRequest) HeadRepoKind { + headRepo := workspace.WorkspaceHeadRepo( + string(httpapi.ProviderKind(repo)), + httpapi.ProviderHost(repo), + repo.Owner, + repo.Name, + mr.HeadRepoCloneURL, + ) + if headRepo == nil { + return HeadRepoKindSameRepo + } + if *headRepo == "" { + return HeadRepoKindUnknown + } + return HeadRepoKindFork +} + // BuildDetail assembles the canonical Pull detail response for adjacent HTTP // domains such as explicit sync without duplicating Pull presentation logic. func (s *Handler) BuildDetail( diff --git a/internal/server/pullapi/types.go b/internal/server/pullapi/types.go index eb3ecccbb4..c2178e5287 100644 --- a/internal/server/pullapi/types.go +++ b/internal/server/pullapi/types.go @@ -53,6 +53,14 @@ type workflowApprovalResponse struct { Count int `json:"count"` } +type HeadRepoKind string + +const ( + HeadRepoKindSameRepo HeadRepoKind = "same_repo" + HeadRepoKindFork HeadRepoKind = "fork" + HeadRepoKindUnknown HeadRepoKind = "unknown" +) + type MergeRequestDetailResponse struct { MergeRequest *db.MergeRequest `json:"merge_request"` Events []mergeRequestEventResponse `json:"events"` @@ -61,6 +69,7 @@ type MergeRequestDetailResponse struct { RepoName string `json:"repo_name"` PlatformHost string `json:"platform_host"` PlatformHeadSHA string `json:"platform_head_sha"` + HeadRepoKind HeadRepoKind `json:"head_repo_kind" enum:"same_repo,fork,unknown"` PlatformBaseSHA string `json:"platform_base_sha"` ReviewedHeadSHA string `json:"reviewed_head_sha"` DiffHeadSHA string `json:"diff_head_sha"` diff --git a/internal/server/route_metadata_test.go b/internal/server/route_metadata_test.go index d60707b552..dfeee4d9d5 100644 --- a/internal/server/route_metadata_test.go +++ b/internal/server/route_metadata_test.go @@ -31,6 +31,7 @@ var allowedAPITags = map[string]struct{}{ "Sync": {}, "System": {}, "Workspaces": {}, + "Workflows": {}, } // collectMetadataFailures walks an OpenAPI document and returns one entry per diff --git a/internal/server/server.go b/internal/server/server.go index 91257a3f43..06f10a4a15 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -43,6 +43,7 @@ import ( "go.kenn.io/forge/internal/server/pullapi" "go.kenn.io/forge/internal/server/repobrowserapi" "go.kenn.io/forge/internal/server/workspaceapi" + "go.kenn.io/forge/internal/server/workflowapi" "go.kenn.io/forge/internal/systemclipboard" "go.kenn.io/forge/internal/telemetry" "go.kenn.io/forge/internal/tokenauth" @@ -220,6 +221,7 @@ type Server struct { repoBrowserAPI *repobrowserapi.Handler pullAPI *pullapi.Handler issueAPI *issueapi.Handler + workflowAPI *workflowapi.Handler pullLifecycle pullLifecycle workspaceAPI *workspaceapi.Handler // activityAfterItemsForTest pauses Activity between its two identity reads @@ -1046,6 +1048,12 @@ func newServer( ) } s.updateCatalogStripEnvVars(bootCatalog.TokenEnvNames()) + s.workflowAPI = workflowapi.New(workflowapi.Deps{ + Resolver: repoResolver, + Syncer: syncer, + RepoOperations: s.repoOperations, + Now: s.now, + }) s.pullAPI = pullapi.New(pullapi.Deps{ DB: database, Resolver: repoResolver, diff --git a/internal/server/workflowapi/handler.go b/internal/server/workflowapi/handler.go new file mode 100644 index 0000000000..9e3bc6ca9c --- /dev/null +++ b/internal/server/workflowapi/handler.go @@ -0,0 +1,55 @@ +// Package workflowapi owns provider-neutral workflow catalog, run, job, and dispatch HTTP behavior. +package workflowapi + +import ( + "time" + + "go.kenn.io/forge/internal/db" + ghclient "go.kenn.io/forge/internal/github" + "go.kenn.io/forge/internal/server/httpapi" +) + +const ( + capabilityReadWorkflows = "read_workflows" + capabilityReadWorkflowRuns = "read_workflow_runs" + capabilityWorkflowDispatch = "workflow_dispatch" +) + +const ( + maxWorkflowInputs = 25 + maxWorkflowInputPayload = 65_535 +) + +type Deps struct { + Resolver *httpapi.RepositoryResolver + Syncer *ghclient.Syncer + RepoOperations func(db.Repo) httpapi.RepoOperations + Now func() time.Time +} + +type Handler struct { + resolver *httpapi.RepositoryResolver + syncer *ghclient.Syncer + repoOperations func(db.Repo) httpapi.RepoOperations + now func() time.Time +} + +func New(deps Deps) *Handler { + now := deps.Now + if now == nil { + now = time.Now + } + return &Handler{ + resolver: deps.Resolver, + syncer: deps.Syncer, + repoOperations: deps.RepoOperations, + now: now, + } +} + +func (h *Handler) operations(repo db.Repo) httpapi.RepoOperations { + if h.repoOperations == nil { + return httpapi.RepoOperations{} + } + return h.repoOperations(repo) +} diff --git a/internal/server/workflowapi/routes.go b/internal/server/workflowapi/routes.go new file mode 100644 index 0000000000..00b391e577 --- /dev/null +++ b/internal/server/workflowapi/routes.go @@ -0,0 +1,450 @@ +package workflowapi + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "slices" + "strings" + "time" + "unicode/utf8" + + "github.com/danielgtaylor/huma/v2" + "go.kenn.io/forge/internal/db" + "go.kenn.io/forge/internal/platform" + "go.kenn.io/forge/internal/server/httpapi" +) + +func (h *Handler) Register(api huma.API) { + base := "/actions/{provider}/{owner}/{name}" + hostBase := "/host/{platform_host}" + base + register(api, "list-workflows", http.MethodGet, base+"/workflows", http.StatusOK, "List manual workflows", h.listCatalog) + register(api, "list-workflows-on-host", http.MethodGet, hostBase+"/workflows", http.StatusOK, "List manual workflows", h.listCatalogOnHost) + register(api, "list-workflow-runs", http.MethodGet, base+"/runs", http.StatusOK, "List workflow runs", h.listRuns) + register(api, "list-workflow-runs-on-host", http.MethodGet, hostBase+"/runs", http.StatusOK, "List workflow runs", h.listRunsOnHost) + register(api, "list-workflow-run-jobs", http.MethodGet, base+"/runs/{run_id}/jobs", http.StatusOK, "List workflow run jobs", h.listJobs) + register(api, "list-workflow-run-jobs-on-host", http.MethodGet, hostBase+"/runs/{run_id}/jobs", http.StatusOK, "List workflow run jobs", h.listJobsOnHost) + register(api, "dispatch-workflow", http.MethodPost, base+"/workflows/{workflow_id}/dispatch", http.StatusAccepted, "Dispatch workflow", h.dispatch) + register(api, "dispatch-workflow-on-host", http.MethodPost, hostBase+"/workflows/{workflow_id}/dispatch", http.StatusAccepted, "Dispatch workflow", h.dispatchOnHost) +} + +func register[I, O any](api huma.API, operationID, method, path string, status int, summary string, handler func(context.Context, *I) (*O, error)) { + huma.Register(api, huma.Operation{ + OperationID: operationID, Method: method, Path: path, DefaultStatus: status, + Summary: summary, Tags: []string{"Workflows"}, + }, handler) +} + +type resolvedRepository struct { + repo *db.Repo + fence db.RepositoryRouteFence +} + +func (h *Handler) resolve(ctx context.Context, provider, host, owner, name, capability string) (resolvedRepository, error) { + if h == nil || h.resolver == nil { + return resolvedRepository{}, httpapi.Internal("repository resolver unavailable") + } + repo, err := h.resolver.RequireRouteCapability(ctx, provider, host, owner, name, capability) + if err != nil { + return resolvedRepository{}, err + } + fence, found, err := h.resolver.CaptureRepositoryRouteFence(ctx, *repo) + if err != nil { + return resolvedRepository{}, httpapi.Internal("capture repository identity failed") + } + if !found { + return resolvedRepository{}, repositoryIdentityChangedProblem() + } + return resolvedRepository{repo: repo, fence: fence}, nil +} + +func (h *Handler) confirm(ctx context.Context, resolved resolvedRepository) error { + matches, err := h.resolver.RepositoryRouteFenceMatches(ctx, *resolved.repo, resolved.fence) + if err != nil { + return httpapi.Internal("confirm repository identity failed") + } + if !matches { + return repositoryIdentityChangedProblem() + } + return nil +} + +func repositoryIdentityChangedProblem() error { + return httpapi.NotFound(httpapi.CodeRepoNotFound, "repository identity no longer matches this route", nil) +} + +func (h *Handler) registry() *platform.Registry { + if h == nil || h.syncer == nil { + return nil + } + return h.syncer.Registry() +} + +func (h *Handler) listCatalog(ctx context.Context, input *repositoryInput) (*catalogOutput, error) { + resolved, err := h.resolve(ctx, input.Provider, input.PlatformHost, input.Owner, input.Name, capabilityReadWorkflows) + if err != nil { + return nil, err + } + registry := h.registry() + if registry == nil { + return nil, httpapi.UnsupportedCapability(*resolved.repo, capabilityReadWorkflows) + } + reader, err := registry.WorkflowCatalogReader(httpapi.ProviderKind(*resolved.repo), httpapi.ProviderHost(*resolved.repo)) + if err != nil { + return nil, httpapi.UnsupportedCapability(*resolved.repo, capabilityReadWorkflows) + } + ref := httpapi.PlatformRepoRef(*resolved.repo) + workflows, err := reader.ListManualWorkflows(ctx, ref) + if err != nil { + return nil, httpapi.ProviderCallProblem(err, string(ref.Platform), ref.Host) + } + environments, err := reader.ListWorkflowEnvironments(ctx, ref) + if err != nil { + return nil, httpapi.ProviderCallProblem(err, string(ref.Platform), ref.Host) + } + if err := h.confirm(ctx, resolved); err != nil { + return nil, err + } + repo := h.resolver.Ref(*resolved.repo) + operations := h.operations(*resolved.repo) + repo.Operations = &operations + return &catalogOutput{Body: WorkflowCatalogResponse{ + Repo: repo, Workflows: workflowDefinitions(workflows), Environments: workflowEnvironments(environments), + }}, nil +} + +func (h *Handler) listCatalogOnHost(ctx context.Context, input *hostRepositoryInput) (*catalogOutput, error) { + return h.listCatalog(ctx, &repositoryInput{Provider: input.Provider, PlatformHost: input.PlatformHost, Owner: input.Owner, Name: input.Name}) +} + +func (h *Handler) listRuns(ctx context.Context, input *workflowRunsInput) (*runsOutput, error) { + if input.WorkflowID != "" && strings.TrimSpace(input.WorkflowID) == "" { + return nil, httpapi.Validation("query.workflow_id", "workflow_id must not be blank") + } + resolved, err := h.resolve(ctx, input.Provider, input.PlatformHost, input.Owner, input.Name, capabilityReadWorkflowRuns) + if err != nil { + return nil, err + } + registry := h.registry() + if registry == nil { + return nil, httpapi.UnsupportedCapability(*resolved.repo, capabilityReadWorkflowRuns) + } + reader, err := registry.WorkflowRunReader(httpapi.ProviderKind(*resolved.repo), httpapi.ProviderHost(*resolved.repo)) + if err != nil { + return nil, httpapi.UnsupportedCapability(*resolved.repo, capabilityReadWorkflowRuns) + } + query := platform.WorkflowRunQuery{ + WorkflowID: strings.TrimSpace(input.WorkflowID), Event: strings.TrimSpace(input.Event), Branch: strings.TrimSpace(input.Branch), + Cursor: input.Cursor, PerPage: input.PerPage, + } + page, err := reader.ListWorkflowRuns(ctx, httpapi.PlatformRepoRef(*resolved.repo), query) + if err != nil { + return nil, httpapi.ProviderCallProblem(err, string(httpapi.ProviderKind(*resolved.repo)), httpapi.ProviderHost(*resolved.repo)) + } + if err := h.confirm(ctx, resolved); err != nil { + return nil, err + } + return &runsOutput{Body: WorkflowRunsResponse{ + Repo: h.resolver.Ref(*resolved.repo), Items: workflowRuns(page.Items), NextCursor: page.NextCursor, Exhausted: page.Exhausted, + }}, nil +} + +func (h *Handler) listRunsOnHost(ctx context.Context, input *hostWorkflowRunsInput) (*runsOutput, error) { + return h.listRuns(ctx, &workflowRunsInput{ + Provider: input.Provider, PlatformHost: input.PlatformHost, Owner: input.Owner, Name: input.Name, + WorkflowID: input.WorkflowID, Event: input.Event, Branch: input.Branch, Cursor: input.Cursor, PerPage: input.PerPage, + }) +} + +func (h *Handler) listJobs(ctx context.Context, input *workflowJobsInput) (*jobsOutput, error) { + runID := strings.TrimSpace(input.RunID) + if runID == "" { + return nil, httpapi.Validation("path.run_id", "run_id must not be blank") + } + resolved, err := h.resolve(ctx, input.Provider, input.PlatformHost, input.Owner, input.Name, capabilityReadWorkflowRuns) + if err != nil { + return nil, err + } + registry := h.registry() + if registry == nil { + return nil, httpapi.UnsupportedCapability(*resolved.repo, capabilityReadWorkflowRuns) + } + reader, err := registry.WorkflowRunReader(httpapi.ProviderKind(*resolved.repo), httpapi.ProviderHost(*resolved.repo)) + if err != nil { + return nil, httpapi.UnsupportedCapability(*resolved.repo, capabilityReadWorkflowRuns) + } + jobs, err := reader.ListWorkflowRunJobs(ctx, httpapi.PlatformRepoRef(*resolved.repo), runID) + if err != nil { + return nil, httpapi.ProviderCallProblem(err, string(httpapi.ProviderKind(*resolved.repo)), httpapi.ProviderHost(*resolved.repo)) + } + if err := h.confirm(ctx, resolved); err != nil { + return nil, err + } + return &jobsOutput{Body: WorkflowJobsResponse{Repo: h.resolver.Ref(*resolved.repo), Items: workflowJobs(jobs)}}, nil +} + +func (h *Handler) listJobsOnHost(ctx context.Context, input *hostWorkflowJobsInput) (*jobsOutput, error) { + return h.listJobs(ctx, &workflowJobsInput{Provider: input.Provider, PlatformHost: input.PlatformHost, Owner: input.Owner, Name: input.Name, RunID: input.RunID}) +} + +func (h *Handler) dispatch(ctx context.Context, input *workflowDispatchInput) (*dispatchOutput, error) { + workflowID := strings.TrimSpace(input.WorkflowID) + if workflowID == "" { + return nil, httpapi.Validation("path.workflow_id", "workflow_id must not be blank") + } + if strings.TrimSpace(input.Body.Ref) == "" { + return nil, httpapi.Validation("body.ref", "ref must not be blank") + } + if len(input.Body.Inputs) > maxWorkflowInputs { + return nil, httpapi.Validation("body.inputs", fmt.Sprintf("inputs must contain at most %d values", maxWorkflowInputs)) + } + encodedInputs, err := json.Marshal(input.Body.Inputs) + if err != nil { + return nil, httpapi.Validation("body.inputs", "inputs must be JSON encodable") + } + if utf8.RuneCount(encodedInputs) > maxWorkflowInputPayload { + return nil, httpapi.Validation("body.inputs", fmt.Sprintf("encoded inputs must not exceed %d characters", maxWorkflowInputPayload)) + } + resolved, err := h.resolve(ctx, input.Provider, input.PlatformHost, input.Owner, input.Name, capabilityReadWorkflows) + if err != nil { + return nil, err + } + if !httpapi.CapabilityEnabled(h.resolver.Ref(*resolved.repo).Capabilities, capabilityWorkflowDispatch) { + return nil, httpapi.UnsupportedCapability(*resolved.repo, capabilityWorkflowDispatch) + } + registry := h.registry() + if registry == nil { + return nil, httpapi.UnsupportedCapability(*resolved.repo, capabilityWorkflowDispatch) + } + catalogReader, err := registry.WorkflowCatalogReader(httpapi.ProviderKind(*resolved.repo), httpapi.ProviderHost(*resolved.repo)) + if err != nil { + return nil, httpapi.UnsupportedCapability(*resolved.repo, capabilityReadWorkflows) + } + dispatcher, err := registry.WorkflowDispatcher(httpapi.ProviderKind(*resolved.repo), httpapi.ProviderHost(*resolved.repo)) + if err != nil { + return nil, httpapi.UnsupportedCapability(*resolved.repo, capabilityWorkflowDispatch) + } + ref := httpapi.PlatformRepoRef(*resolved.repo) + workflows, err := catalogReader.ListManualWorkflows(ctx, ref) + if err != nil { + return nil, httpapi.ProviderCallProblem(err, string(ref.Platform), ref.Host) + } + environments, err := catalogReader.ListWorkflowEnvironments(ctx, ref) + if err != nil { + return nil, httpapi.ProviderCallProblem(err, string(ref.Platform), ref.Host) + } + definition, found := findWorkflow(workflows, workflowID) + if !found { + return nil, httpapi.NotFound(httpapi.CodeNotFound, "workflow not found", map[string]any{"workflowId": workflowID}) + } + if input.Body.ExpectedDefinitionSHA != definition.DefinitionSHA { + return nil, httpapi.Conflict(httpapi.CodeConflict, "workflow definition changed", map[string]any{ + "reason": "workflow_definition_changed", "expectedDefinitionSha": input.Body.ExpectedDefinitionSHA, "definitionSha": definition.DefinitionSHA, + }) + } + if !definition.Available { + return nil, httpapi.Conflict(httpapi.CodeConflict, "workflow is unavailable", map[string]any{"reason": definition.UnavailableReason}) + } + if err := validateWorkflowInputs(definition.Inputs, environments, input.Body.Inputs); err != nil { + return nil, err + } + request := platform.WorkflowDispatchRequest{ + WorkflowID: workflowID, Ref: strings.TrimSpace(input.Body.Ref), Inputs: input.Body.Inputs, + ExpectedDefinitionSHA: input.Body.ExpectedDefinitionSHA, + } + var result platform.WorkflowDispatchResult + matched, err := h.resolver.GuardRepositoryRouteFence(ctx, *resolved.repo, resolved.fence, func() error { + availability := h.operations(*resolved.repo).DispatchWorkflow + if !availability.Available { + return dispatchUnavailableProblem(*resolved.repo, availability) + } + var dispatchErr error + result, dispatchErr = dispatcher.DispatchWorkflow(ctx, ref, request) + if dispatchErr != nil { + return httpapi.ProviderMutationProblem(dispatchErr, string(ref.Platform), ref.Host) + } + return nil + }) + if err != nil { + return nil, err + } + if !matched { + return nil, repositoryIdentityChangedProblem() + } + response := WorkflowDispatchResponse{Accepted: result.Accepted, LocatingRun: result.LocatingRun} + if result.Run != nil { + run := workflowRun(*result.Run) + response.Run = &run + } + return &dispatchOutput{Status: http.StatusAccepted, Body: response}, nil +} + +func (h *Handler) dispatchOnHost(ctx context.Context, input *hostWorkflowDispatchInput) (*dispatchOutput, error) { + return h.dispatch(ctx, &workflowDispatchInput{ + Provider: input.Provider, PlatformHost: input.PlatformHost, Owner: input.Owner, Name: input.Name, + WorkflowID: input.WorkflowID, Body: input.Body, + }) +} + +func dispatchUnavailableProblem(repo db.Repo, availability httpapi.OperationAvailability) error { + switch availability.Code { + case "unsupported_capability": + capability := availability.RequiredCapability + if capability == "" { + capability = capabilityWorkflowDispatch + } + return httpapi.UnsupportedCapability(repo, capability) + case "rate_limited": + details := map[string]any{"reason": "rate_limited", "provider": string(httpapi.ProviderKind(repo)), "platformHost": httpapi.ProviderHost(repo)} + if availability.RetryAt != "" { + details["retryAfter"] = availability.RetryAt + } + return httpapi.NewProblem(http.StatusTooManyRequests, httpapi.CodeRateLimited, availability.UnavailableReason, details) + default: + reason := availability.Code + if reason == "" { + reason = "operation_unavailable" + } + return httpapi.Conflict(httpapi.CodeConflict, availability.UnavailableReason, map[string]any{"reason": reason}) + } +} + +func validateWorkflowInputs(definitions []platform.WorkflowInput, environments []platform.WorkflowEnvironment, inputs map[string]any) error { + byName := make(map[string]platform.WorkflowInput, len(definitions)) + for _, definition := range definitions { + byName[definition.Name] = definition + } + for name := range inputs { + if _, ok := byName[name]; !ok { + return httpapi.Validation("body.inputs."+name, "unknown workflow input") + } + } + environmentNames := make([]string, 0, len(environments)) + for _, environment := range environments { + environmentNames = append(environmentNames, environment.Name) + } + for _, definition := range definitions { + value, present := inputs[definition.Name] + if !present { + if definition.Required && !definition.HasDefault { + return httpapi.Validation("body.inputs."+definition.Name, "required workflow input is missing") + } + continue + } + field := "body.inputs." + definition.Name + switch definition.Type { + case platform.WorkflowInputString: + if _, ok := value.(string); !ok { + return httpapi.Validation(field, "workflow input must be a string") + } + case platform.WorkflowInputBoolean: + if _, ok := value.(bool); !ok { + return httpapi.Validation(field, "workflow input must be a boolean") + } + case platform.WorkflowInputNumber: + if !isJSONNumber(value) { + return httpapi.Validation(field, "workflow input must be a number") + } + case platform.WorkflowInputChoice: + choice, ok := value.(string) + if !ok || !slices.Contains(definition.Options, choice) { + return httpapi.Validation(field, "workflow input must be one of the declared choices", definition.Options...) + } + case platform.WorkflowInputEnvironment: + environment, ok := value.(string) + if !ok || !slices.Contains(environmentNames, environment) { + return httpapi.Validation(field, "workflow input must name a live environment", environmentNames...) + } + default: + return httpapi.Validation(field, "workflow input has an unsupported type") + } + } + return nil +} + +func isJSONNumber(value any) bool { + switch value.(type) { + case float64, float32, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, json.Number: + return true + default: + return false + } +} + +func findWorkflow(workflows []platform.WorkflowDefinition, id string) (platform.WorkflowDefinition, bool) { + for _, workflow := range workflows { + if workflow.ID == id { + return workflow, true + } + } + return platform.WorkflowDefinition{}, false +} + +func workflowDefinitions(values []platform.WorkflowDefinition) []WorkflowDefinitionResponse { + out := make([]WorkflowDefinitionResponse, 0, len(values)) + for _, value := range values { + inputs := make([]WorkflowInputResponse, 0, len(value.Inputs)) + for _, input := range value.Inputs { + inputs = append(inputs, WorkflowInputResponse{ + Name: input.Name, Description: input.Description, Required: input.Required, Type: input.Type, + Default: input.Default, HasDefault: input.HasDefault, Options: slices.Clone(input.Options), + }) + } + out = append(out, WorkflowDefinitionResponse{ + ID: value.ID, Name: value.Name, Path: value.Path, State: value.State, WebURL: value.WebURL, + DefinitionSHA: value.DefinitionSHA, Inputs: inputs, Available: value.Available, UnavailableReason: value.UnavailableReason, + }) + } + return out +} + +func workflowEnvironments(values []platform.WorkflowEnvironment) []WorkflowEnvironmentResponse { + out := make([]WorkflowEnvironmentResponse, 0, len(values)) + for _, value := range values { + out = append(out, WorkflowEnvironmentResponse{Name: value.Name}) + } + return out +} + +func workflowRuns(values []platform.WorkflowRun) []WorkflowRunResponse { + out := make([]WorkflowRunResponse, 0, len(values)) + for _, value := range values { + out = append(out, workflowRun(value)) + } + return out +} + +func workflowRun(value platform.WorkflowRun) WorkflowRunResponse { + return WorkflowRunResponse{ + ID: value.ID, WorkflowID: value.WorkflowID, RunNumber: value.RunNumber, Name: value.Name, Event: value.Event, + Ref: value.Ref, HeadSHA: value.HeadSHA, Actor: value.Actor, Status: value.Status, Conclusion: value.Conclusion, + CreatedAt: formatTime(value.CreatedAt), UpdatedAt: formatTime(value.UpdatedAt), WebURL: value.WebURL, + } +} + +func workflowJobs(values []platform.WorkflowRunJob) []WorkflowRunJobResponse { + out := make([]WorkflowRunJobResponse, 0, len(values)) + for _, value := range values { + steps := make([]WorkflowRunStepResponse, 0, len(value.Steps)) + for _, step := range value.Steps { + steps = append(steps, WorkflowRunStepResponse{ + Number: step.Number, Name: step.Name, Status: step.Status, Conclusion: step.Conclusion, + StartedAt: formatTime(step.StartedAt), CompletedAt: formatTime(step.CompletedAt), + }) + } + out = append(out, WorkflowRunJobResponse{ + ID: value.ID, Name: value.Name, Status: value.Status, Conclusion: value.Conclusion, + StartedAt: formatTime(value.StartedAt), CompletedAt: formatTime(value.CompletedAt), WebURL: value.WebURL, Steps: steps, + }) + } + return out +} + +func formatTime(value time.Time) string { + if value.IsZero() { + return "" + } + return value.UTC().Format(time.RFC3339) +} + diff --git a/internal/server/workflowapi/routes_test.go b/internal/server/workflowapi/routes_test.go new file mode 100644 index 0000000000..8f0fd4a1be --- /dev/null +++ b/internal/server/workflowapi/routes_test.go @@ -0,0 +1,332 @@ +package workflowapi + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/danielgtaylor/huma/v2" + "github.com/danielgtaylor/huma/v2/adapters/humago" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.kenn.io/forge/internal/db" + "go.kenn.io/forge/internal/testutil/dbtest" + ghclient "go.kenn.io/forge/internal/github" + "go.kenn.io/forge/internal/platform" + "go.kenn.io/forge/internal/server/httpapi" +) + +type workflowTestProvider struct { + caps platform.Capabilities + catalog []platform.WorkflowDefinition + environments []platform.WorkflowEnvironment + runs platform.Page[platform.WorkflowRun] + jobs []platform.WorkflowRunJob + dispatch platform.WorkflowDispatchResult + catalogErr error + runsErr error + jobsErr error + dispatchErr error + dispatches []platform.WorkflowDispatchRequest + onCatalog func() +} + +func (p *workflowTestProvider) Platform() platform.Kind { return platform.KindGitHub } +func (p *workflowTestProvider) Host() string { return platform.DefaultGitHubHost } +func (p *workflowTestProvider) Capabilities() platform.Capabilities { return p.caps } +func (p *workflowTestProvider) ListManualWorkflows(context.Context, platform.RepoRef) ([]platform.WorkflowDefinition, error) { + if p.onCatalog != nil { p.onCatalog() } + return p.catalog, p.catalogErr +} +func (p *workflowTestProvider) ListWorkflowEnvironments(context.Context, platform.RepoRef) ([]platform.WorkflowEnvironment, error) { + return p.environments, nil +} +func (p *workflowTestProvider) ListWorkflowRuns(_ context.Context, _ platform.RepoRef, query platform.WorkflowRunQuery) (platform.Page[platform.WorkflowRun], error) { + if query.PerPage != 20 || query.Cursor != "cursor-1" || query.WorkflowID != "release.yml" || query.Event != "workflow_dispatch" || query.Branch != "main" { + return platform.Page[platform.WorkflowRun]{}, &platform.Error{Code: platform.ErrCodeInvalidArgument, Provider: platform.KindGitHub, PlatformHost: platform.DefaultGitHubHost, Field: "query", Err: errors.New("query mismatch")} + } + return p.runs, p.runsErr +} +func (p *workflowTestProvider) ListWorkflowRunJobs(context.Context, platform.RepoRef, string) ([]platform.WorkflowRunJob, error) { + return p.jobs, p.jobsErr +} +func (p *workflowTestProvider) DispatchWorkflow(_ context.Context, _ platform.RepoRef, request platform.WorkflowDispatchRequest) (platform.WorkflowDispatchResult, error) { + p.dispatches = append(p.dispatches, request) + return p.dispatch, p.dispatchErr +} + +func workflowFixture(t *testing.T, provider *workflowTestProvider, operation httpapi.OperationAvailability) (*http.ServeMux, *db.DB) { + t.Helper() + database := dbtest.Open(t) + identity := db.GitHubRepoIdentity(platform.DefaultGitHubHost, "acme", "widget") + identity.PlatformRepoID = "R_widget" + _, err := database.UpsertRepo(t.Context(), identity) + require.NoError(t, err) + registry, err := platform.NewRegistry(provider) + require.NoError(t, err) + syncer := ghclient.NewSyncerWithRegistry(registry, database, nil, nil, time.Minute, nil, nil) + t.Cleanup(syncer.Stop) + resolver := httpapi.NewRepositoryResolver(httpapi.RepositoryResolverDeps{ + DB: database, + ProviderCapabilities: func(platform.Kind, string) (platform.Capabilities, error) { return provider.caps, nil }, + }) + handler := New(Deps{ + Resolver: resolver, + Syncer: syncer, + RepoOperations: func(db.Repo) httpapi.RepoOperations { return httpapi.RepoOperations{DispatchWorkflow: operation} }, + Now: func() time.Time { return time.Date(2026, 8, 27, 12, 0, 0, 0, time.UTC) }, + }) + mux := http.NewServeMux() + config := huma.DefaultConfig("workflow test", "0") + config.OpenAPIPath, config.DocsPath, config.SchemasPath = "", "", "" + api := humago.NewWithPrefix(mux, "/api/v1", config) + handler.Register(api) + return mux, database +} + +func workflowRequest(t *testing.T, mux http.Handler, method, path string, body any) (int, map[string]any) { + t.Helper() + var payload bytes.Buffer + if body != nil { require.NoError(t, json.NewEncoder(&payload).Encode(body)) } + req := httptest.NewRequest(method, "/api/v1"+path, &payload) + if body != nil { req.Header.Set("Content-Type", "application/json") } + recorder := httptest.NewRecorder() + mux.ServeHTTP(recorder, req) + var decoded map[string]any + require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &decoded), recorder.Body.String()) + return recorder.Code, decoded +} + +func workflowDefinitionFixture() platform.WorkflowDefinition { + return platform.WorkflowDefinition{ + ID: "release.yml", Name: "Release", Path: ".github/workflows/release.yml", State: "active", + WebURL: "https://github.com/acme/widget/actions/workflows/release.yml", DefinitionSHA: "definition-v1", + Available: true, + Inputs: []platform.WorkflowInput{ + {Name: "version", Required: true, Type: platform.WorkflowInputString}, + {Name: "dry_run", Type: platform.WorkflowInputBoolean, Default: false, HasDefault: true}, + {Name: "retries", Type: platform.WorkflowInputNumber, Default: 2, HasDefault: true}, + {Name: "channel", Type: platform.WorkflowInputChoice, Options: []string{"stable", "beta"}}, + {Name: "target", Type: platform.WorkflowInputEnvironment}, + }, + } +} + +func TestWorkflowCatalogRoutesUseStableRepositoryIdentity(t *testing.T) { + provider := &workflowTestProvider{ + caps: platform.Capabilities{ReadWorkflows: true, ReadWorkflowRuns: true, WorkflowDispatch: true}, + catalog: []platform.WorkflowDefinition{workflowDefinitionFixture()}, + environments: []platform.WorkflowEnvironment{{Name: "production"}, {Name: "staging"}}, + } + mux, _ := workflowFixture(t, provider, httpapi.OperationAvailability{Available: true}) + for _, path := range []string{ + "/actions/github/acme/widget/workflows", + "/host/github.com/actions/github/acme/widget/workflows", + } { + status, body := workflowRequest(t, mux, http.MethodGet, path, nil) + require.Equal(t, http.StatusOK, status) + repo := body["repo"].(map[string]any) + assert.Equal(t, "R_widget", repo["platform_repo_id"]) + assert.Equal(t, "acme/widget", repo["repo_path"]) + assert.Equal(t, true, repo["operations"].(map[string]any)["dispatch_workflow"].(map[string]any)["available"]) + workflow := body["workflows"].([]any)[0].(map[string]any) + assert.Equal(t, "definition-v1", workflow["definition_sha"]) + assert.Equal(t, false, workflow["inputs"].([]any)[1].(map[string]any)["default"]) + assert.Equal(t, float64(2), workflow["inputs"].([]any)[2].(map[string]any)["default"]) + assert.Equal(t, "production", body["environments"].([]any)[0].(map[string]any)["name"]) + } +} + +func TestWorkflowRunsAndJobsPreserveProviderContracts(t *testing.T) { + started := time.Date(2026, 8, 27, 14, 30, 0, 0, time.FixedZone("CEST", 2*60*60)) + provider := &workflowTestProvider{ + caps: platform.Capabilities{ReadWorkflows: true, ReadWorkflowRuns: true, WorkflowDispatch: true}, + runs: platform.Page[platform.WorkflowRun]{Items: []platform.WorkflowRun{{ID: "41", WorkflowID: "release.yml", RunNumber: 7, CreatedAt: started, UpdatedAt: started.Add(time.Minute)}}, NextCursor: "cursor-2", Exhausted: false}, + jobs: []platform.WorkflowRunJob{ + {ID: "job-2", Name: "deploy", StartedAt: started.Add(time.Minute)}, + {ID: "job-1", Name: "build", StartedAt: started}, + }, + } + mux, _ := workflowFixture(t, provider, httpapi.OperationAvailability{Available: true}) + status, body := workflowRequest(t, mux, http.MethodGet, "/actions/github/acme/widget/runs?workflow_id=release.yml&event=workflow_dispatch&branch=main&cursor=cursor-1&per_page=20", nil) + require.Equal(t, http.StatusOK, status) + assert.Equal(t, "cursor-2", body["next_cursor"]) + assert.Equal(t, false, body["exhausted"]) + assert.Equal(t, "2026-08-27T12:30:00Z", body["items"].([]any)[0].(map[string]any)["created_at"]) + status, body = workflowRequest(t, mux, http.MethodGet, "/host/github.com/actions/github/acme/widget/runs/41/jobs", nil) + require.Equal(t, http.StatusOK, status) + items := body["items"].([]any) + assert.Equal(t, "job-2", items[0].(map[string]any)["id"]) + assert.Equal(t, "2026-08-27T12:31:00Z", items[0].(map[string]any)["started_at"]) + assert.Equal(t, "job-1", items[1].(map[string]any)["id"]) +} + +func TestWorkflowRoutesRejectUnsupportedAndMalformedIdentifiers(t *testing.T) { + provider := &workflowTestProvider{caps: platform.Capabilities{}} + mux, _ := workflowFixture(t, provider, httpapi.OperationAvailability{}) + status, body := workflowRequest(t, mux, http.MethodGet, "/actions/github/acme/widget/workflows", nil) + assert.Equal(t, http.StatusConflict, status) + assert.Equal(t, "unsupportedCapability", body["code"]) + assert.Equal(t, "read_workflows", body["details"].(map[string]any)["capability"]) + + provider.caps = platform.Capabilities{ReadWorkflows: true} + status, body = workflowRequest(t, mux, http.MethodGet, "/actions/github/acme/widget/runs", nil) + assert.Equal(t, http.StatusConflict, status) + assert.Equal(t, "read_workflow_runs", body["details"].(map[string]any)["capability"]) + + status, body = workflowRequest( + t, + mux, + http.MethodPost, + "/actions/github/acme/widget/workflows/release.yml/dispatch", + map[string]any{ + "ref": "main", + "expected_definition_sha": "definition-v1", + "inputs": map[string]any{}, + }, + ) + assert.Equal(t, http.StatusConflict, status) + assert.Equal(t, "workflow_dispatch", body["details"].(map[string]any)["capability"]) + + provider.caps = platform.Capabilities{ReadWorkflows: true, ReadWorkflowRuns: true} + for _, path := range []string{ + "/actions/github/acme/widget/runs?workflow_id=%20", + "/actions/github/acme/widget/runs/%20/jobs", + } { + status, body = workflowRequest(t, mux, http.MethodGet, path, nil) + assert.Equal(t, http.StatusBadRequest, status) + assert.Equal(t, "validationError", body["code"]) + assert.NotEmpty(t, body["details"].(map[string]any)["field"]) + } + status, body = workflowRequest( + t, + mux, + http.MethodPost, + "/actions/github/acme/widget/workflows/%20/dispatch", + map[string]any{ + "ref": "main", + "expected_definition_sha": "definition-v1", + "inputs": map[string]any{}, + }, + ) + assert.Equal(t, http.StatusBadRequest, status) + assert.Equal(t, "validationError", body["code"]) + assert.Equal(t, "path.workflow_id", body["details"].(map[string]any)["field"]) +} + +func TestWorkflowCatalogFailsClosedWhenRouteIdentityChanges(t *testing.T) { + provider := &workflowTestProvider{caps: platform.Capabilities{ReadWorkflows: true}, catalog: []platform.WorkflowDefinition{workflowDefinitionFixture()}} + mux, database := workflowFixture(t, provider, httpapi.OperationAvailability{}) + provider.onCatalog = func() { + now := time.Now().UTC() + _, _, err := database.ReconcileRepositoryObservation(t.Context(), db.RepoIdentity{Platform: "github", PlatformHost: "github.com", PlatformRepoID: "R_widget", Owner: "acme", Name: "renamed"}, now) + require.NoError(t, err) + _, _, err = database.ReconcileRepositoryObservation(t.Context(), db.RepoIdentity{Platform: "github", PlatformHost: "github.com", PlatformRepoID: "R_replacement", Owner: "acme", Name: "widget"}, now.Add(time.Second)) + require.NoError(t, err) + } + status, body := workflowRequest(t, mux, http.MethodGet, "/actions/github/acme/widget/workflows", nil) + assert.Equal(t, http.StatusNotFound, status) + assert.Equal(t, "repoNotFound", body["code"]) +} + +func TestWorkflowDispatchValidatesLiveDefinitionBeforeMutation(t *testing.T) { + tests := []struct { + name string + body map[string]any + wantField string + wantStatus int + wantReason string + }{ + {name: "blank ref", body: map[string]any{"ref": " ", "expected_definition_sha": "definition-v1", "inputs": map[string]any{"version": "1"}}, wantField: "body.ref", wantStatus: 400}, + {name: "unknown input", body: map[string]any{"ref": "main", "expected_definition_sha": "definition-v1", "inputs": map[string]any{"version": "1", "extra": "x"}}, wantField: "body.inputs.extra", wantStatus: 400}, + {name: "missing required", body: map[string]any{"ref": "main", "expected_definition_sha": "definition-v1", "inputs": map[string]any{}}, wantField: "body.inputs.version", wantStatus: 400}, + {name: "wrong boolean", body: map[string]any{"ref": "main", "expected_definition_sha": "definition-v1", "inputs": map[string]any{"version": "1", "dry_run": "false"}}, wantField: "body.inputs.dry_run", wantStatus: 400}, + {name: "wrong number", body: map[string]any{"ref": "main", "expected_definition_sha": "definition-v1", "inputs": map[string]any{"version": "1", "retries": "2"}}, wantField: "body.inputs.retries", wantStatus: 400}, + {name: "invalid choice", body: map[string]any{"ref": "main", "expected_definition_sha": "definition-v1", "inputs": map[string]any{"version": "1", "channel": "nightly"}}, wantField: "body.inputs.channel", wantStatus: 400}, + {name: "invalid environment", body: map[string]any{"ref": "main", "expected_definition_sha": "definition-v1", "inputs": map[string]any{"version": "1", "target": "qa"}}, wantField: "body.inputs.target", wantStatus: 400}, + {name: "stale definition", body: map[string]any{"ref": "main", "expected_definition_sha": "old", "inputs": map[string]any{"version": "1"}}, wantStatus: 409, wantReason: "workflow_definition_changed"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + provider := &workflowTestProvider{caps: platform.Capabilities{ReadWorkflows: true, WorkflowDispatch: true}, catalog: []platform.WorkflowDefinition{workflowDefinitionFixture()}, environments: []platform.WorkflowEnvironment{{Name: "production"}}} + mux, _ := workflowFixture(t, provider, httpapi.OperationAvailability{Available: true}) + status, body := workflowRequest(t, mux, http.MethodPost, "/actions/github/acme/widget/workflows/release.yml/dispatch", test.body) + assert.Equal(t, test.wantStatus, status) + assert.Empty(t, provider.dispatches) + if test.wantField != "" { assert.Equal(t, test.wantField, body["details"].(map[string]any)["field"]) } + if test.wantReason != "" { assert.Equal(t, test.wantReason, body["details"].(map[string]any)["reason"]) } + }) + } +} + +func TestWorkflowDispatchEnforcesInputLimitsAndOperationGate(t *testing.T) { + provider := &workflowTestProvider{caps: platform.Capabilities{ReadWorkflows: true, WorkflowDispatch: true}, catalog: []platform.WorkflowDefinition{workflowDefinitionFixture()}} + mux, _ := workflowFixture(t, provider, httpapi.OperationAvailability{Available: true}) + many := map[string]any{} + for i := range 26 { many[string(rune('a'+i))] = "x" } + for _, inputs := range []map[string]any{many, {"version": strings.Repeat("x", 65536)}} { + status, _ := workflowRequest(t, mux, http.MethodPost, "/actions/github/acme/widget/workflows/release.yml/dispatch", map[string]any{"ref": "main", "expected_definition_sha": "definition-v1", "inputs": inputs}) + assert.Equal(t, http.StatusBadRequest, status) + assert.Empty(t, provider.dispatches) + } + status, _ := workflowRequest( + t, + mux, + http.MethodPost, + "/actions/github/acme/widget/workflows/release.yml/dispatch", + map[string]any{ + "ref": "main", + "expected_definition_sha": "definition-v1", + "inputs": map[string]any{"version": strings.Repeat("é", 32761)}, + }, + ) + assert.Equal(t, http.StatusAccepted, status) + require.Len(t, provider.dispatches, 1) + provider.dispatches = nil + mux, _ = workflowFixture(t, provider, httpapi.OperationAvailability{Available: false, Code: "rate_limited", UnavailableReason: "REST rate limit exhausted"}) + status, body := workflowRequest(t, mux, http.MethodPost, "/actions/github/acme/widget/workflows/release.yml/dispatch", map[string]any{"ref": "main", "expected_definition_sha": "definition-v1", "inputs": map[string]any{"version": "1"}}) + assert.Equal(t, http.StatusTooManyRequests, status) + assert.Equal(t, "rateLimited", body["code"]) + assert.Empty(t, provider.dispatches) +} + +func TestWorkflowDispatchMapsProviderRejectionAndMutationUncertainty(t *testing.T) { + for _, test := range []struct { + name string + err error + wantStatus int + wantCode string + }{ + {name: "typed rejection", err: &platform.Error{Code: platform.ErrCodeInvalidArgument, Provider: platform.KindGitHub, PlatformHost: platform.DefaultGitHubHost, Field: "ref", Err: errors.New("ref rejected")}, wantStatus: 400, wantCode: "badRequest"}, + {name: "transport uncertainty", err: errors.New("connection reset"), wantStatus: 502, wantCode: "mutationOutcomeUnknown"}, + } { + t.Run(test.name, func(t *testing.T) { + provider := &workflowTestProvider{caps: platform.Capabilities{ReadWorkflows: true, WorkflowDispatch: true}, catalog: []platform.WorkflowDefinition{workflowDefinitionFixture()}, dispatchErr: test.err} + mux, _ := workflowFixture(t, provider, httpapi.OperationAvailability{Available: true}) + status, body := workflowRequest(t, mux, http.MethodPost, "/actions/github/acme/widget/workflows/release.yml/dispatch", map[string]any{"ref": "main", "expected_definition_sha": "definition-v1", "inputs": map[string]any{"version": "1"}}) + assert.Equal(t, test.wantStatus, status) + assert.Equal(t, test.wantCode, body["code"]) + require.Len(t, provider.dispatches, 1) + }) + } +} + +func TestWorkflowDispatchResponsePreservesLocatingAndConcreteRun(t *testing.T) { + for _, result := range []platform.WorkflowDispatchResult{ + {Accepted: true, LocatingRun: true}, + {Accepted: true, Run: &platform.WorkflowRun{ID: "run-9", WorkflowID: "release.yml", CreatedAt: time.Date(2026, 8, 27, 12, 0, 0, 0, time.UTC)}}, + } { + provider := &workflowTestProvider{caps: platform.Capabilities{ReadWorkflows: true, WorkflowDispatch: true}, catalog: []platform.WorkflowDefinition{workflowDefinitionFixture()}, dispatch: result} + mux, _ := workflowFixture(t, provider, httpapi.OperationAvailability{Available: true}) + status, body := workflowRequest(t, mux, http.MethodPost, "/host/github.com/actions/github/acme/widget/workflows/release.yml/dispatch", map[string]any{"ref": "main", "expected_definition_sha": "definition-v1", "inputs": map[string]any{"version": "1"}}) + require.Equal(t, http.StatusAccepted, status) + assert.Equal(t, result.LocatingRun, body["locating_run"]) + if result.Run != nil { assert.Equal(t, "run-9", body["run"].(map[string]any)["id"]) } + } +} diff --git a/internal/server/workflowapi/types.go b/internal/server/workflowapi/types.go new file mode 100644 index 0000000000..a59825c304 --- /dev/null +++ b/internal/server/workflowapi/types.go @@ -0,0 +1,175 @@ +package workflowapi + +import ( + "go.kenn.io/forge/internal/platform" + "go.kenn.io/forge/internal/server/httpapi" +) + +type repositoryInput struct { + Provider string `path:"provider"` + PlatformHost string + Owner string `path:"owner"` + Name string `path:"name"` +} + +type hostRepositoryInput struct { + Provider string `path:"provider"` + PlatformHost string `path:"platform_host"` + Owner string `path:"owner"` + Name string `path:"name"` +} + +type workflowRunsInput struct { + Provider string `path:"provider"` + PlatformHost string + Owner string `path:"owner"` + Name string `path:"name"` + WorkflowID string `query:"workflow_id"` + Event string `query:"event"` + Branch string `query:"branch"` + Cursor string `query:"cursor"` + PerPage int `query:"per_page" default:"20" minimum:"1" maximum:"100"` +} + +type hostWorkflowRunsInput struct { + Provider string `path:"provider"` + PlatformHost string `path:"platform_host"` + Owner string `path:"owner"` + Name string `path:"name"` + WorkflowID string `query:"workflow_id"` + Event string `query:"event"` + Branch string `query:"branch"` + Cursor string `query:"cursor"` + PerPage int `query:"per_page" default:"20" minimum:"1" maximum:"100"` +} + +type workflowJobsInput struct { + Provider string `path:"provider"` + PlatformHost string + Owner string `path:"owner"` + Name string `path:"name"` + RunID string `path:"run_id"` +} + +type hostWorkflowJobsInput struct { + Provider string `path:"provider"` + PlatformHost string `path:"platform_host"` + Owner string `path:"owner"` + Name string `path:"name"` + RunID string `path:"run_id"` +} + +type workflowDispatchInput struct { + Provider string `path:"provider"` + PlatformHost string + Owner string `path:"owner"` + Name string `path:"name"` + WorkflowID string `path:"workflow_id"` + Body workflowDispatchBody +} + +type hostWorkflowDispatchInput struct { + Provider string `path:"provider"` + PlatformHost string `path:"platform_host"` + Owner string `path:"owner"` + Name string `path:"name"` + WorkflowID string `path:"workflow_id"` + Body workflowDispatchBody +} + +type workflowDispatchBody struct { + Ref string `json:"ref"` + Inputs map[string]any `json:"inputs"` + ExpectedDefinitionSHA string `json:"expected_definition_sha"` +} + +type WorkflowInputResponse struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + Required bool `json:"required"` + Type platform.WorkflowInputType `json:"type" enum:"string,number,boolean,choice,environment"` + Default any `json:"default,omitempty"` + HasDefault bool `json:"has_default"` + Options []string `json:"options,omitempty"` +} + +type WorkflowDefinitionResponse struct { + ID string `json:"id"` + Name string `json:"name"` + Path string `json:"path"` + State string `json:"state"` + WebURL string `json:"web_url" format:"uri"` + DefinitionSHA string `json:"definition_sha"` + Inputs []WorkflowInputResponse `json:"inputs"` + Available bool `json:"available"` + UnavailableReason string `json:"unavailable_reason,omitempty"` +} + +type WorkflowEnvironmentResponse struct { + Name string `json:"name"` +} + +type WorkflowCatalogResponse struct { + Repo httpapi.RepoRefResponse `json:"repo"` + Workflows []WorkflowDefinitionResponse `json:"workflows"` + Environments []WorkflowEnvironmentResponse `json:"environments"` +} + +type WorkflowRunStepResponse struct { + Number int `json:"number"` + Name string `json:"name"` + Status string `json:"status"` + Conclusion string `json:"conclusion"` + StartedAt string `json:"started_at,omitempty" format:"date-time"` + CompletedAt string `json:"completed_at,omitempty" format:"date-time"` +} + +type WorkflowRunJobResponse struct { + ID string `json:"id"` + Name string `json:"name"` + Status string `json:"status"` + Conclusion string `json:"conclusion"` + StartedAt string `json:"started_at,omitempty" format:"date-time"` + CompletedAt string `json:"completed_at,omitempty" format:"date-time"` + WebURL string `json:"web_url,omitempty" format:"uri"` + Steps []WorkflowRunStepResponse `json:"steps"` +} + +type WorkflowRunResponse struct { + ID string `json:"id"` + WorkflowID string `json:"workflow_id"` + RunNumber int64 `json:"run_number"` + Name string `json:"name"` + Event string `json:"event"` + Ref string `json:"ref"` + HeadSHA string `json:"head_sha"` + Actor string `json:"actor"` + Status string `json:"status"` + Conclusion string `json:"conclusion"` + CreatedAt string `json:"created_at,omitempty" format:"date-time"` + UpdatedAt string `json:"updated_at,omitempty" format:"date-time"` + WebURL string `json:"web_url,omitempty" format:"uri"` +} + +type WorkflowRunsResponse struct { + Repo httpapi.RepoRefResponse `json:"repo"` + Items []WorkflowRunResponse `json:"items"` + NextCursor string `json:"next_cursor,omitempty"` + Exhausted bool `json:"exhausted"` +} + +type WorkflowJobsResponse struct { + Repo httpapi.RepoRefResponse `json:"repo"` + Items []WorkflowRunJobResponse `json:"items"` +} + +type WorkflowDispatchResponse struct { + Accepted bool `json:"accepted"` + LocatingRun bool `json:"locating_run"` + Run *WorkflowRunResponse `json:"run,omitempty"` +} + +type catalogOutput = httpapi.BodyOutput[WorkflowCatalogResponse] +type runsOutput = httpapi.BodyOutput[WorkflowRunsResponse] +type jobsOutput = httpapi.BodyOutput[WorkflowJobsResponse] +type dispatchOutput = httpapi.AcceptedBodyOutput[WorkflowDispatchResponse] From e9b05ae7c5351cd7b8ceafc7e674b2d92560ac30 Mon Sep 17 00:00:00 2001 From: Max Mill Date: Fri, 28 Aug 2026 16:56:04 +0200 Subject: [PATCH 09/41] feat: make workflow actions an opt-in UI mode Actions stays disabled by default and one persisted mode switch controls every workflow surface and polling owner while leaving provider APIs available. --- context/config-persistence.md | 1 + frontend/openapi/openapi.yaml | 3 +++ frontend/src/lib/api/generated/schema.ts | 1 + frontend/src/lib/api/types.ts | 1 + .../settings/ModeVisibilitySettings.svelte | 1 + .../settings/ModeVisibilitySettings.test.ts | 4 ++++ .../src/lib/components/settings/settingsPanels.ts | 2 +- internal/apiclient/generated/client.gen.go | 1 + internal/config/config.go | 5 +++++ internal/config/config_test.go | 3 +++ internal/server/config_reload_test.go | 5 +++++ internal/server/settings_handlers.go | 4 ++++ internal/server/settings_test.go | 14 ++++++++++++++ 13 files changed, 44 insertions(+), 1 deletion(-) diff --git a/context/config-persistence.md b/context/config-persistence.md index bf0e946d20..e020cedc6f 100644 --- a/context/config-persistence.md +++ b/context/config-persistence.md @@ -12,5 +12,6 @@ back to TOML. - When zero is meaningful, represent the saved value as optional so TOML `omitempty` cannot turn explicit zero into an unset default; the round-trip test must cover zero (`internal/config/config.go::Terminal`). - Whole-file settings mutations must hold `configReloadMu` before `cfgMu` while applying and saving changes, or the watcher can restore a stale snapshot between writes (`internal/server/settings_handlers.go::updateSettings`). - Partial settings request objects must use pointer fields and merge only fields that were present; reusing persisted value structs collapses omission into zero values (`internal/server/settings_handlers.go::mcpSettingsUpdate`). +- `modes.actions` is a false-by-default UI visibility preference: it gates every Actions surface while provider workflow capabilities and HTTP routes remain available (`internal/config/config.go::ModeVisibility`). - `roborev.init_managed_clones` is a hot-reloaded, false-by-default setup policy. It persists through the partial `roborev` settings object and the committed workspace API snapshot; only the effective Roborev endpoint remains in the startup-bound restart snapshot (`internal/config/config.go::Roborev`, `internal/server/config_reload.go::startupConfigSnapshot`, `internal/server/workspaceapi/config.go::ConfigSnapshot`). - Repository preset config stores only named custom definitions; `Global` is a derived UI preset and must never be serialized to TOML. Each member persists provider, provider host, provider-verified repository ID, and a last-known display route; preset create/update/delete use dedicated atomic settings endpoints instead of replacing the collection through generic settings (`internal/config/config.go::RepoPreset`, `internal/server/settings_handlers.go::mutateRepoPresets`). diff --git a/frontend/openapi/openapi.yaml b/frontend/openapi/openapi.yaml index 595fef22ef..4708b1f269 100644 --- a/frontend/openapi/openapi.yaml +++ b/frontend/openapi/openapi.yaml @@ -4457,6 +4457,8 @@ components: ModeVisibility: additionalProperties: false properties: + actions: + type: boolean activity: type: boolean docs: @@ -4475,6 +4477,7 @@ components: - activity - repos - docs + - actions - pulls - issues - reviews diff --git a/frontend/src/lib/api/generated/schema.ts b/frontend/src/lib/api/generated/schema.ts index 9d467fbfe0..0fcf5faa40 100644 --- a/frontend/src/lib/api/generated/schema.ts +++ b/frontend/src/lib/api/generated/schema.ts @@ -6799,6 +6799,7 @@ export interface components { url?: string; }; ModeVisibility: { + actions: boolean; activity: boolean; docs: boolean; issues: boolean; diff --git a/frontend/src/lib/api/types.ts b/frontend/src/lib/api/types.ts index 5ae4060750..d1e1985351 100644 --- a/frontend/src/lib/api/types.ts +++ b/frontend/src/lib/api/types.ts @@ -83,6 +83,7 @@ export const DEFAULT_MODE_VISIBILITY: ModeVisibility = { activity: true, repos: true, docs: false, + actions: false, pulls: true, issues: true, reviews: true, diff --git a/frontend/src/lib/components/settings/ModeVisibilitySettings.svelte b/frontend/src/lib/components/settings/ModeVisibilitySettings.svelte index 34f5406f66..49670852f0 100644 --- a/frontend/src/lib/components/settings/ModeVisibilitySettings.svelte +++ b/frontend/src/lib/components/settings/ModeVisibilitySettings.svelte @@ -40,6 +40,7 @@ { key: "activity", label: "Activity" }, { key: "repos", label: "Repos" }, { key: "docs", label: "Docs" }, + { key: "actions", label: "Actions" }, { key: "pulls", label: "PRs" }, { key: "issues", label: "Issues" }, { key: "reviews", label: "Reviews" }, diff --git a/frontend/src/lib/components/settings/ModeVisibilitySettings.test.ts b/frontend/src/lib/components/settings/ModeVisibilitySettings.test.ts index 6ef12a0db6..ca42b5e49d 100644 --- a/frontend/src/lib/components/settings/ModeVisibilitySettings.test.ts +++ b/frontend/src/lib/components/settings/ModeVisibilitySettings.test.ts @@ -39,6 +39,7 @@ function defaultModes(): ModeVisibility { return { activity: true, repos: true, + actions: false, docs: false, pulls: true, issues: true, @@ -59,6 +60,7 @@ describe("ModeVisibilitySettings", () => { const updated = { ...modes, docs: true, + actions: true, workspaces: false, }; mockPersistSettings.mockReturnValue(Effect.succeed({ modes: updated })); @@ -68,10 +70,12 @@ describe("ModeVisibilitySettings", () => { props: { component: ModeVisibilitySettings, componentProps: { modes, onUpdate } }, }); + expect((screen.getByLabelText("Actions") as HTMLInputElement).checked).toBe(false); expect((screen.getByLabelText("Docs") as HTMLInputElement).checked).toBe(false); expect(screen.queryByLabelText("Messages")).toBeNull(); expect(screen.queryByLabelText("Board")).toBeNull(); + await fireEvent.click(screen.getByLabelText("Actions")); await fireEvent.click(screen.getByLabelText("Docs")); await fireEvent.click(screen.getByLabelText("Workspaces")); await fireEvent.click(screen.getByRole("button", { name: "Save" })); diff --git a/frontend/src/lib/components/settings/settingsPanels.ts b/frontend/src/lib/components/settings/settingsPanels.ts index 11f5a1a9bf..5de7d40f56 100644 --- a/frontend/src/lib/components/settings/settingsPanels.ts +++ b/frontend/src/lib/components/settings/settingsPanels.ts @@ -87,7 +87,7 @@ export const SETTINGS_PANELS: SettingsPanelMeta[] = [ title: "Visible modes", group: "Navigation", description: "Modes shown in the app header", - keywords: "visible modes navigation tabs prs issues reviews docs kata", + keywords: "visible modes navigation tabs prs issues reviews docs kata actions github workflows release dispatch", }, { id: "settings-mcp", diff --git a/internal/apiclient/generated/client.gen.go b/internal/apiclient/generated/client.gen.go index db476d2537..15b7d0a892 100644 --- a/internal/apiclient/generated/client.gen.go +++ b/internal/apiclient/generated/client.gen.go @@ -3173,6 +3173,7 @@ type MergeRequestSummary struct { // ModeVisibility defines model for ModeVisibility. type ModeVisibility struct { + Actions bool `json:"actions"` Activity bool `json:"activity"` Docs bool `json:"docs"` Issues bool `json:"issues"` diff --git a/internal/config/config.go b/internal/config/config.go index 5139dc97dd..1ca79f9b7c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -756,6 +756,7 @@ type ModeVisibility struct { Activity *bool `toml:"activity,omitempty" json:"activity" nullable:"false"` Repos *bool `toml:"repos,omitempty" json:"repos" nullable:"false"` Docs *bool `toml:"docs,omitempty" json:"docs" nullable:"false"` + Actions *bool `toml:"actions,omitempty" json:"actions" nullable:"false"` Pulls *bool `toml:"pulls,omitempty" json:"pulls" nullable:"false"` Issues *bool `toml:"issues,omitempty" json:"issues" nullable:"false"` Reviews *bool `toml:"reviews,omitempty" json:"reviews" nullable:"false"` @@ -767,6 +768,7 @@ func DefaultModeVisibility() ModeVisibility { Activity: new(true), Repos: new(true), Docs: new(false), + Actions: new(false), Pulls: new(true), Issues: new(true), Reviews: new(true), @@ -785,6 +787,9 @@ func (m ModeVisibility) WithDefaults() ModeVisibility { if m.Docs != nil { defaults.Docs = m.Docs } + if m.Actions != nil { + defaults.Actions = m.Actions + } if m.Pulls != nil { defaults.Pulls = m.Pulls } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index bf60cfd5ad..50bc32b836 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -3081,6 +3081,7 @@ name = "b" assert.True(*cfg.Modes.Activity) assert.True(*cfg.Modes.Repos) assert.False(*cfg.Modes.Docs) + assert.False(*cfg.Modes.Actions) assert.True(*cfg.Modes.Pulls) assert.True(*cfg.Modes.Issues) assert.True(*cfg.Modes.Reviews) @@ -3096,6 +3097,7 @@ owner = "a" name = "b" [modes] +actions = true activity = false repos = false docs = false @@ -3114,6 +3116,7 @@ workspaces = false cfg2, err := Load(savePath) require.NoError(err) + assert.True(*cfg2.Modes.Actions) assert.False(*cfg2.Modes.Activity) assert.False(*cfg2.Modes.Repos) assert.False(*cfg2.Modes.Docs) diff --git a/internal/server/config_reload_test.go b/internal/server/config_reload_test.go index 4bb0e690fd..d2734643da 100644 --- a/internal/server/config_reload_test.go +++ b/internal/server/config_reload_test.go @@ -605,6 +605,7 @@ func TestConfigReload_UpdatesModes(t *testing.T) { srv.cfgMu.Lock() gotModes := cloneModeVisibility(srv.cfg.Modes) + originalActions := srv.cfg.Modes.Actions srv.cfgMu.Unlock() assert.True(*gotModes.Docs) assert.False(*gotModes.Workspaces) @@ -613,6 +614,10 @@ func TestConfigReload_UpdatesModes(t *testing.T) { assert.True(*gotModes.Pulls) assert.True(*gotModes.Issues) assert.True(*gotModes.Reviews) + assert.False(*gotModes.Actions) + + *gotModes.Actions = true + assert.False(*originalActions, "cloned Actions pointer must not alias live config") } func TestConfigReload_UpdatesDocFoldersAndRegistry(t *testing.T) { diff --git a/internal/server/settings_handlers.go b/internal/server/settings_handlers.go index 3b5265df22..f54d4df31b 100644 --- a/internal/server/settings_handlers.go +++ b/internal/server/settings_handlers.go @@ -1014,6 +1014,10 @@ func cloneModeVisibility(modes config.ModeVisibility) config.ModeVisibility { v := *modes.Docs out.Docs = &v } + if modes.Actions != nil { + v := *modes.Actions + out.Actions = &v + } if modes.Pulls != nil { v := *modes.Pulls out.Pulls = &v diff --git a/internal/server/settings_test.go b/internal/server/settings_test.go index 8204c77dd8..74a4f2e659 100644 --- a/internal/server/settings_test.go +++ b/internal/server/settings_test.go @@ -676,6 +676,7 @@ func TestHandleUpdateSettingsPersistsModes(t *testing.T) { modes := config.DefaultModeVisibility() *modes.Docs = true *modes.Workspaces = false + *modes.Actions = true rr := doJSON( t, srv, http.MethodPut, "/api/v1/settings", @@ -685,6 +686,7 @@ func TestHandleUpdateSettingsPersistsModes(t *testing.T) { var resp settingsResponse require.NoError(json.NewDecoder(rr.Body).Decode(&resp)) + assert.True(*resp.Modes.Actions) assert.True(*resp.Modes.Docs) assert.False(*resp.Modes.Workspaces) assert.True(*resp.Modes.Activity) @@ -695,6 +697,7 @@ func TestHandleUpdateSettingsPersistsModes(t *testing.T) { cfg2, err := config.Load(cfgPath) require.NoError(err) + assert.True(*cfg2.Modes.Actions) assert.True(*cfg2.Modes.Docs) assert.False(*cfg2.Modes.Workspaces) assert.True(*cfg2.Modes.Activity) @@ -702,6 +705,16 @@ func TestHandleUpdateSettingsPersistsModes(t *testing.T) { assert.True(*cfg2.Modes.Pulls) assert.True(*cfg2.Modes.Issues) assert.True(*cfg2.Modes.Reviews) + + activity := srv.cfg.Activity + activity.TimeRange = "30d" + rr = doJSON(t, srv, http.MethodPut, "/api/v1/settings", updateSettingsRequest{ + Activity: &activity, + }) + require.Equal(http.StatusOK, rr.Code, rr.Body.String()) + cfg3, err := config.Load(cfgPath) + require.NoError(err) + assert.True(*cfg3.Modes.Actions) } func TestHandleUpdateSettingsPublishesPullConfigOnlyAfterPersistence(t *testing.T) { @@ -816,6 +829,7 @@ func assertDefaultModeVisibility(t *testing.T, modes config.ModeVisibility) { assert.True(*modes.Activity) assert.True(*modes.Repos) assert.False(*modes.Docs) + assert.False(*modes.Actions) assert.True(*modes.Pulls) assert.True(*modes.Issues) assert.True(*modes.Reviews) From 03e28442aee6bb80906720ed6a4e01ecd03d2a4a Mon Sep 17 00:00:00 2001 From: Max Mill Date: Fri, 28 Aug 2026 17:03:47 +0200 Subject: [PATCH 10/41] test: include actions in settings fixture --- frontend/src/test/mockApiFetch.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/test/mockApiFetch.ts b/frontend/src/test/mockApiFetch.ts index a4892b36d9..5085dffd68 100644 --- a/frontend/src/test/mockApiFetch.ts +++ b/frontend/src/test/mockApiFetch.ts @@ -303,6 +303,7 @@ export const mockSettings = { }, modes: { activity: true, + actions: false, docs: true, issues: true, pulls: true, From 78385e82708a47969a373b695ba02adcc677ef64 Mon Sep 17 00:00:00 2001 From: Max Mill Date: Fri, 28 Aug 2026 17:11:54 +0200 Subject: [PATCH 11/41] fix: restore workflow capability fixture typing Task 4 made read_workflows, read_workflow_runs, and workflow_dispatch required members of the generated ProviderCapabilitiesResponse type. Add fail-closed local defaults (false) to each of the four defaultProviderCapabilities objects so the front-end sources compile before provider data loads. No runtime, markup, generated code, or behavior change. --- frontend/src/lib/components/detail/IssueDetail.svelte | 3 +++ frontend/src/lib/components/detail/PullDetail.svelte | 3 +++ frontend/src/lib/components/detail/PullDetailPane.svelte | 3 +++ frontend/src/lib/components/repositories/repoSummary.ts | 3 +++ 4 files changed, 12 insertions(+) diff --git a/frontend/src/lib/components/detail/IssueDetail.svelte b/frontend/src/lib/components/detail/IssueDetail.svelte index fb55438021..57b53c86b6 100644 --- a/frontend/src/lib/components/detail/IssueDetail.svelte +++ b/frontend/src/lib/components/detail/IssueDetail.svelte @@ -117,6 +117,9 @@ read_comments: true, read_releases: true, read_ci: true, + read_workflows: false, + read_workflow_runs: false, + workflow_dispatch: false, read_labels: false, read_markdown_images: false, read_authenticated_user: false, diff --git a/frontend/src/lib/components/detail/PullDetail.svelte b/frontend/src/lib/components/detail/PullDetail.svelte index 91b0385967..bfb8444bb6 100644 --- a/frontend/src/lib/components/detail/PullDetail.svelte +++ b/frontend/src/lib/components/detail/PullDetail.svelte @@ -179,6 +179,9 @@ read_comments: true, read_releases: true, read_ci: true, + read_workflows: false, + read_workflow_runs: false, + workflow_dispatch: false, read_labels: false, read_markdown_images: false, read_authenticated_user: false, diff --git a/frontend/src/lib/components/detail/PullDetailPane.svelte b/frontend/src/lib/components/detail/PullDetailPane.svelte index e2b40b20d6..c5285675e5 100644 --- a/frontend/src/lib/components/detail/PullDetailPane.svelte +++ b/frontend/src/lib/components/detail/PullDetailPane.svelte @@ -70,6 +70,9 @@ read_markdown_images: false, read_authenticated_user: false, read_ci: true, + read_workflows: false, + read_workflow_runs: false, + workflow_dispatch: false, comment_mutation: true, thread_reply: false, thread_resolve: false, diff --git a/frontend/src/lib/components/repositories/repoSummary.ts b/frontend/src/lib/components/repositories/repoSummary.ts index 0ab07f3c75..0137c98b01 100644 --- a/frontend/src/lib/components/repositories/repoSummary.ts +++ b/frontend/src/lib/components/repositories/repoSummary.ts @@ -35,6 +35,9 @@ export const defaultProviderCapabilities: ProviderCapabilities = { read_comments: true, read_releases: true, read_ci: true, + read_workflows: false, + read_workflow_runs: false, + workflow_dispatch: false, read_labels: false, read_markdown_images: false, read_authenticated_user: false, From 6760c2e714844e4707b6faeee271598dd65c0119 Mon Sep 17 00:00:00 2001 From: Max Mill Date: Fri, 28 Aug 2026 17:46:52 +0200 Subject: [PATCH 12/41] feat: own workflow actions in the app runtime Effect services now deduplicate reads, bound polling and reconciliation, and retain non-idempotent dispatch outcomes across component navigation without replaying writes. --- frontend/src/lib/api/provider-routes.test.ts | 43 + frontend/src/lib/api/provider-routes.ts | 17 + frontend/src/lib/app-stores.svelte.ts | 3 + frontend/src/lib/app/app-stores.test.ts | 7 + frontend/src/lib/app/layer.ts | 2 + .../stores/workflow-actions-workflow.test.ts | 489 +++++++++++ .../lib/stores/workflow-actions-workflow.ts | 765 ++++++++++++++++++ .../stores/workflow-actions.svelte.test.ts | 223 +++++ .../src/lib/stores/workflow-actions.svelte.ts | 213 +++++ frontend/src/lib/types.ts | 3 + frontend/vitest.node-files.ts | 3 + 11 files changed, 1768 insertions(+) create mode 100644 frontend/src/lib/api/provider-routes.test.ts create mode 100644 frontend/src/lib/stores/workflow-actions-workflow.test.ts create mode 100644 frontend/src/lib/stores/workflow-actions-workflow.ts create mode 100644 frontend/src/lib/stores/workflow-actions.svelte.test.ts create mode 100644 frontend/src/lib/stores/workflow-actions.svelte.ts diff --git a/frontend/src/lib/api/provider-routes.test.ts b/frontend/src/lib/api/provider-routes.test.ts new file mode 100644 index 0000000000..6491281e64 --- /dev/null +++ b/frontend/src/lib/api/provider-routes.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vite-plus/test"; +import type { paths } from "./generated/schema.js"; +import { providerActionsPath, type ProviderRouteRef } from "./provider-routes.js"; + +const github: ProviderRouteRef = { + provider: "gh", + platformHost: "github.com", + owner: "octo", + name: "repo", + repoPath: "octo/repo", +}; + +const enterprise: ProviderRouteRef = { + provider: "github", + platformHost: "github.example.com", + owner: "octo", + name: "repo", + repoPath: "octo/repo", +}; + +describe("providerActionsPath", () => { + it("uses the provider-default route for the default host", () => { + const routes: ReadonlyArray = [ + providerActionsPath(github, "/workflows"), + providerActionsPath(github, "/runs"), + providerActionsPath(github, "/runs/{run_id}/jobs"), + providerActionsPath(github, "/workflows/{workflow_id}/dispatch"), + ]; + + expect(routes).toEqual([ + "/actions/{provider}/{owner}/{name}/workflows", + "/actions/{provider}/{owner}/{name}/runs", + "/actions/{provider}/{owner}/{name}/runs/{run_id}/jobs", + "/actions/{provider}/{owner}/{name}/workflows/{workflow_id}/dispatch", + ]); + }); + + it("uses the host-prefixed route for a non-default host", () => { + expect(providerActionsPath(enterprise, "/workflows")).toBe( + "/host/{platform_host}/actions/{provider}/{owner}/{name}/workflows", + ); + }); +}); diff --git a/frontend/src/lib/api/provider-routes.ts b/frontend/src/lib/api/provider-routes.ts index e8f5f3074d..c9356e40b2 100644 --- a/frontend/src/lib/api/provider-routes.ts +++ b/frontend/src/lib/api/provider-routes.ts @@ -127,6 +127,23 @@ export function providerItemPath(kind: "pulls" | "issues", ref: ProviderRouteRef return `/${kind}/{provider}/{owner}/{name}/{number}${suffix}`; } +type ActionsSuffix = + | "/workflows" + | "/runs" + | "/runs/{run_id}/jobs" + | "/workflows/{workflow_id}/dispatch"; + +type ActionsPath = + | `/actions/{provider}/{owner}/{name}${S}` + | `/host/{platform_host}/actions/{provider}/{owner}/{name}${S}`; + +export function providerActionsPath(ref: ProviderRouteRef, suffix: S): ActionsPath { + if (shouldUseHostRoute(ref)) { + return `/host/{platform_host}/actions/{provider}/{owner}/{name}${suffix}`; + } + return `/actions/{provider}/{owner}/{name}${suffix}`; +} + type RepoSuffix = | "" | "/browser/asset" diff --git a/frontend/src/lib/app-stores.svelte.ts b/frontend/src/lib/app-stores.svelte.ts index e1cac26cc4..8619aacfcf 100644 --- a/frontend/src/lib/app-stores.svelte.ts +++ b/frontend/src/lib/app-stores.svelte.ts @@ -29,6 +29,7 @@ import { createGroupingStore } from "./stores/grouping.svelte.js"; import { createDetailActivityViewStore } from "./stores/detail-activity-view.svelte.js"; import { createCollapsedReposStore } from "./stores/collapsedRepos.svelte.js"; import { createSettingsStore } from "./stores/settings.svelte.js"; +import { createWorkflowActionsStore } from "./stores/workflow-actions.svelte.js"; import { beginTerminalSettingsHydration } from "./stores/terminal-settings-persistence.js"; import { beginWorkspaceSettingsHydration } from "./stores/workspace-settings-persistence.js"; import { beginRoborevSettingsHydration } from "./stores/roborev-settings-persistence.js"; @@ -82,6 +83,7 @@ export function createAppStores(options: AppStoreOptions): AppStoreComposition { const detailActivityView = createDetailActivityViewStore(); const collapsedRepos = createCollapsedReposStore(); const settingsStore = createSettingsStore(); + const workflowActions = createWorkflowActionsStore({ runtime: appRuntime }); const detailStarProjection: { current?: (ref: ProviderRouteRef, number: number, starred: boolean, envelopeTick: number) => void; } = {}; @@ -363,6 +365,7 @@ export function createAppStores(options: AppStoreOptions): AppStoreComposition { collapsedRepos, settings: settingsStore, events: eventsStore, + workflowActions, }; let roborevClient: RoborevClient | undefined; diff --git a/frontend/src/lib/app/app-stores.test.ts b/frontend/src/lib/app/app-stores.test.ts index 4e1987d77c..8fe1510b32 100644 --- a/frontend/src/lib/app/app-stores.test.ts +++ b/frontend/src/lib/app/app-stores.test.ts @@ -28,5 +28,12 @@ describe("app store composition", () => { expect(composition.stores.issues.getIssues()).toEqual([]); expect(composition.stores.activity.getActivityItems()).toEqual([]); expect(composition.stores.grouping.getGroupByRepo()).toBe(true); + expect(composition.stores.workflowActions.getRuns({ + provider: "github", + platformHost: "github.com", + owner: "acme", + name: "widgets", + repoPath: "acme/widgets", + })).toEqual([]); }); }); diff --git a/frontend/src/lib/app/layer.ts b/frontend/src/lib/app/layer.ts index b616401294..9cde2bbe5e 100644 --- a/frontend/src/lib/app/layer.ts +++ b/frontend/src/lib/app/layer.ts @@ -31,6 +31,7 @@ import { ProjectMutationWorkflowLive } from "../components/terminal/project-muta import { WorkspaceRuntimeWorkflowLive } from "../components/terminal/workspace-runtime-workflow.js"; import { RepoSummaryWorkflowLive } from "../components/repositories/repo-summary-workflow.js"; import { ToolingStatusWorkflowLive } from "../stores/tooling-status-workflow.js"; +import { WorkflowActionsWorkflowLive } from "../stores/workflow-actions-workflow.js"; export function makeAppLiveLayer(generatedApiLayer: Layer.Layer) { const browserBoundaryLive = Layer.mergeAll( @@ -68,6 +69,7 @@ export function makeAppLiveLayer(generatedApiLayer: Layer.Layer) { WorkspaceRuntimeWorkflowLive, RepoSummaryWorkflowLive, ToolingStatusWorkflowLive, + WorkflowActionsWorkflowLive, ); const applicationWorkflowsLive = Layer.mergeAll(SettingsWorkflowLive, providerWorkflowsLive); diff --git a/frontend/src/lib/stores/workflow-actions-workflow.test.ts b/frontend/src/lib/stores/workflow-actions-workflow.test.ts new file mode 100644 index 0000000000..28ad4e7ed0 --- /dev/null +++ b/frontend/src/lib/stores/workflow-actions-workflow.test.ts @@ -0,0 +1,489 @@ +import { assert, it, vi } from "@effect/vitest"; +import { Effect, Fiber, Layer } from "effect"; +import { TestClock } from "effect/testing"; + +import type { components } from "../api/generated/schema.js"; +import { makeGeneratedApiLayer, type GeneratedClient } from "../api/generated-api.js"; +import type { ProviderRouteRef } from "../api/provider-routes.js"; +import { + WorkflowActionsWorkflow, + WorkflowActionsWorkflowLive, + workflowRepositoryKey, +} from "./workflow-actions-workflow.js"; + +type WorkflowCatalog = components["schemas"]["WorkflowCatalogResponse"]; +type WorkflowDispatchResponse = components["schemas"]["WorkflowDispatchResponse"]; +type WorkflowJobs = components["schemas"]["WorkflowJobsResponse"]; +type WorkflowRun = components["schemas"]["WorkflowRunResponse"]; +type WorkflowRuns = components["schemas"]["WorkflowRunsResponse"]; + +const github: ProviderRouteRef = { + provider: "gh", + platformHost: "github.com", + owner: "octo", + name: "repo", + repoPath: "/octo//repo/", +}; + +function apiRepo(ref: ProviderRouteRef = github) { + return { + provider: ref.provider === "gh" ? "github" : ref.provider, + platform_host: ref.platformHost ?? "github.com", + owner: ref.owner, + name: ref.name, + repo_path: ref.repoPath.replace(/^\/+|\/+$/g, "").replace(/\/{2,}/g, "/"), + }; +} + +function catalog(ref: ProviderRouteRef = github): WorkflowCatalog { + return { + repo: apiRepo(ref), + environments: [{ name: "production" }], + workflows: [ + { + available: true, + definition_sha: "definition-a", + id: "deploy.yml", + inputs: [], + name: "Deploy", + path: ".github/workflows/deploy.yml", + state: "active", + web_url: "https://example.test/workflows/deploy.yml", + }, + ], + }; +} + +function run(overrides: Partial = {}): WorkflowRun { + return { + actor: "octocat", + conclusion: "", + created_at: "1970-01-01T00:00:00.000Z", + event: "workflow_dispatch", + head_sha: "head-a", + id: "run-1", + name: "Deploy", + ref: "main", + run_number: 12, + status: "in_progress", + web_url: "https://example.test/runs/1", + workflow_id: "deploy.yml", + ...overrides, + }; +} + +interface MockRequestOptions { + readonly params?: { readonly path?: unknown } | undefined; + readonly signal?: AbortSignal | undefined; + readonly body?: unknown; +} + +interface ApiProbeOptions { + readonly catalog?: (call: number, options: MockRequestOptions) => WorkflowCatalog | Promise; + readonly runs?: (call: number, options: MockRequestOptions) => WorkflowRuns | Promise; + readonly jobs?: (call: number, options: MockRequestOptions) => WorkflowJobs | Promise; + readonly dispatch?: ( + call: number, + options: MockRequestOptions, + ) => WorkflowDispatchResponse | Promise | { readonly error: unknown; readonly response: Response }; +} + +interface ApiProbe { + readonly calls: { catalog: number; runs: number; jobs: number; dispatch: number }; + readonly observed: Array<{ + readonly method: "GET" | "POST"; + readonly path: string; + readonly options: MockRequestOptions; + }>; + readonly client: GeneratedClient; +} + +function makeApiProbe(options: ApiProbeOptions = {}): ApiProbe { + const calls = { catalog: 0, runs: 0, jobs: 0, dispatch: 0 }; + const observed: ApiProbe["observed"] = []; + const get = vi.fn(async (path: string, requestOptions: MockRequestOptions) => { + observed.push({ method: "GET", path, options: requestOptions }); + if (path.endsWith("/workflows")) { + calls.catalog += 1; + const data = await (options.catalog?.(calls.catalog, requestOptions) ?? catalog()); + return { data, response: new Response(null, { status: 200 }) }; + } + if (path.endsWith("/jobs")) { + calls.jobs += 1; + const data = await (options.jobs?.(calls.jobs, requestOptions) ?? { repo: apiRepo(), items: [] }); + return { data, response: new Response(null, { status: 200 }) }; + } + calls.runs += 1; + const data = await (options.runs?.(calls.runs, requestOptions) ?? { + repo: apiRepo(), + items: [run({ status: "completed", conclusion: "success" })], + exhausted: true, + }); + return { data, response: new Response(null, { status: 200 }) }; + }); + const post = vi.fn(async (path: string, requestOptions: MockRequestOptions) => { + observed.push({ method: "POST", path, options: requestOptions }); + calls.dispatch += 1; + const result = await (options.dispatch?.(calls.dispatch, requestOptions) ?? { + accepted: true, + locating_run: false, + run: run(), + }); + if ("error" in result) return result; + return { data: result, response: new Response(null, { status: 202 }) }; + }); + const client = { + GET: get, + POST: post, + PUT: vi.fn(), + DELETE: vi.fn(), + } as unknown as GeneratedClient; + return { calls, observed, client }; +} + +function withWorkflow(probe: ApiProbe, program: Effect.Effect) { + const layer = WorkflowActionsWorkflowLive.pipe(Layer.provide(makeGeneratedApiLayer(probe.client))); + return program.pipe(Effect.provide(layer)); +} + +const settle = Effect.repeat(Effect.yieldNow, { times: 8 }); + +function dispatchInput(ref: ProviderRouteRef = github) { + return { + ref, + workflowId: "deploy.yml", + expectedDefinitionSha: "definition-a", + dispatchRef: "main", + inputs: {}, + actor: "octocat", + } as const; +} + +it.effect("keys shared reads by canonical provider, resolved host, owner, name, and normalized repo path", () => { + const probe = makeApiProbe(); + const alternatePath = { ...github, repoPath: "octo/another-checkout" }; + return withWorkflow( + probe, + Effect.gen(function* () { + const workflow = yield* WorkflowActionsWorkflow; + assert.strictEqual(workflowRepositoryKey(github), "github\u0000github.com\u0000octo\u0000repo\u0000octo/repo"); + const first = yield* workflow.watchRepository("surface-a", github, () => {}).pipe(Effect.forkChild); + const second = yield* workflow.watchRepository("surface-b", alternatePath, () => {}).pipe(Effect.forkChild); + yield* settle; + + assert.strictEqual(probe.calls.catalog, 2); + assert.strictEqual(probe.calls.runs, 2); + const paths = probe.observed.map((entry) => entry.path); + assert.strictEqual(paths.filter((path) => path.endsWith("/workflows")).length, 2); + assert.strictEqual(paths.filter((path) => path.endsWith("/runs")).length, 2); + const firstOptions = probe.observed[0]?.options; + assert.deepStrictEqual(firstOptions?.params?.path, { provider: "github", owner: "octo", name: "repo" }); + yield* Fiber.interrupt(second); + }), + ); +}); + +it.effect("moves one owner's latest demand and shares a repository poll across other owners", () => { + const probe = makeApiProbe(); + const other = { ...github, owner: "acme", name: "other", repoPath: "acme/other" }; + return withWorkflow( + probe, + Effect.gen(function* () { + const workflow = yield* WorkflowActionsWorkflow; + const first = yield* workflow.watchRepository("surface-a", github, () => {}).pipe(Effect.forkChild); + const shared = yield* workflow.watchRepository("surface-b", github, () => {}).pipe(Effect.forkChild); + yield* settle; + assert.strictEqual(probe.calls.runs, 1); + + const replacement = yield* workflow.watchRepository("surface-a", other, () => {}).pipe(Effect.forkChild); + yield* settle; + assert.strictEqual(probe.calls.runs, 2); + + yield* Fiber.interrupt(first); + yield* TestClock.adjust("30 seconds"); + assert.strictEqual(probe.calls.runs, 4); + yield* Fiber.interrupt(shared); + yield* Fiber.interrupt(replacement); + }), + ); +}); + +it.effect("polls non-terminal runs after 5 seconds and terminal-only runs after 30 seconds", () => { + const probe = makeApiProbe({ + runs: (call) => ({ + repo: apiRepo(), + items: [run(call === 1 ? {} : { status: "completed", conclusion: "success" })], + exhausted: true, + }), + }); + return withWorkflow( + probe, + Effect.gen(function* () { + const workflow = yield* WorkflowActionsWorkflow; + const owner = yield* workflow.watchRepository("surface", github, () => {}).pipe(Effect.forkChild); + yield* settle; + assert.strictEqual(probe.calls.runs, 1); + + yield* TestClock.adjust("4999 millis"); + assert.strictEqual(probe.calls.runs, 1); + yield* TestClock.adjust("1 millis"); + yield* settle; + assert.strictEqual(probe.calls.runs, 2); + + yield* TestClock.adjust("29999 millis"); + assert.strictEqual(probe.calls.runs, 2); + yield* TestClock.adjust("1 millis"); + yield* settle; + assert.strictEqual(probe.calls.runs, 3); + yield* Fiber.interrupt(owner); + }), + ); +}); + +it.effect("stops idle polling after the final repository owner releases", () => { + const probe = makeApiProbe(); + return withWorkflow( + probe, + Effect.gen(function* () { + const workflow = yield* WorkflowActionsWorkflow; + const owner = yield* workflow.watchRepository("surface", github, () => {}).pipe(Effect.forkChild); + yield* settle; + yield* Fiber.interrupt(owner); + yield* TestClock.adjust("2 minutes"); + assert.strictEqual(probe.calls.runs, 1); + assert.strictEqual(probe.calls.catalog, 1); + }), + ); +}); + +it.effect("retains an accepted dispatch after its surface releases and reconciles it to terminal", () => { + const probe = makeApiProbe({ + dispatch: () => ({ accepted: true, locating_run: false, run: run() }), + runs: (call) => ({ + repo: apiRepo(), + items: [run(call === 1 ? {} : { status: "completed", conclusion: "success" })], + exhausted: true, + }), + }); + return withWorkflow( + probe, + Effect.gen(function* () { + const workflow = yield* WorkflowActionsWorkflow; + const owner = yield* workflow.watchRepository("surface", github, () => {}).pipe(Effect.forkChild); + yield* settle; + const request = yield* workflow.dispatch(dispatchInput()); + yield* settle; + yield* Fiber.interrupt(owner); + + let state = (yield* workflow.snapshot(github)).dispatches.find((item) => item.request.id === request.id); + assert.strictEqual(state?.kind, "succeeded"); + assert.strictEqual(state?.kind === "succeeded" ? state.run?.status : undefined, "in_progress"); + + yield* TestClock.adjust("5 seconds"); + yield* settle; + state = (yield* workflow.snapshot(github)).dispatches.find((item) => item.request.id === request.id); + assert.strictEqual(state?.kind, "succeeded"); + assert.strictEqual(state?.kind === "succeeded" ? state.run?.conclusion : undefined, "success"); + assert.strictEqual(probe.calls.dispatch, 1); + }), + ); +}); + +it.effect("bounds locating an accepted response without a run ID at 60 seconds", () => { + const probe = makeApiProbe({ + dispatch: () => ({ accepted: true, locating_run: true }), + runs: () => ({ repo: apiRepo(), items: [], exhausted: true }), + }); + return withWorkflow( + probe, + Effect.gen(function* () { + const workflow = yield* WorkflowActionsWorkflow; + const request = yield* workflow.dispatch(dispatchInput()); + yield* settle; + let state = (yield* workflow.snapshot(github)).dispatches.find((item) => item.request.id === request.id); + assert.strictEqual(state?.kind, "locating"); + + yield* TestClock.adjust("60 seconds"); + yield* settle; + state = (yield* workflow.snapshot(github)).dispatches.find((item) => item.request.id === request.id); + assert.strictEqual(state?.kind, "locating_timed_out"); + const readsAtDeadline = probe.calls.runs; + yield* TestClock.adjust("30 seconds"); + assert.strictEqual(probe.calls.runs, readsAtDeadline); + assert.strictEqual(probe.calls.dispatch, 1); + }), + ); +}); + + +it.effect("publishes a definite dispatch rejection as failed without replaying POST", () => { + const problem = { + code: "validationError", + detail: "The workflow ref is invalid.", + title: "Invalid workflow dispatch", + type: "about:blank", + }; + const probe = makeApiProbe({ + dispatch: () => ({ error: problem, response: new Response(null, { status: 400 }) }), + }); + return withWorkflow( + probe, + Effect.gen(function* () { + const workflow = yield* WorkflowActionsWorkflow; + const request = yield* workflow.dispatch(dispatchInput()); + yield* settle; + const state = (yield* workflow.snapshot(github)).dispatches.find((item) => item.request.id === request.id); + assert.strictEqual(state?.kind, "failed"); + assert.strictEqual(probe.calls.dispatch, 1); + }), + ); +}); + +it.effect("treats a dispatch transport failure as uncertain without replaying POST", () => { + const probe = makeApiProbe({ + dispatch: () => Promise.reject(new Error("connection reset after write")), + runs: () => ({ repo: apiRepo(), items: [], exhausted: true }), + }); + return withWorkflow( + probe, + Effect.gen(function* () { + const workflow = yield* WorkflowActionsWorkflow; + const request = yield* workflow.dispatch(dispatchInput()); + yield* settle; + const state = (yield* workflow.snapshot(github)).dispatches.find((item) => item.request.id === request.id); + assert.strictEqual(state?.kind, "uncertain"); + assert.strictEqual(probe.calls.dispatch, 1); + }), + ); +}); +it.effect("publishes locating_timed_out when reconciliation reads keep failing", () => { + const probe = makeApiProbe({ + dispatch: () => ({ accepted: true, locating_run: true }), + runs: () => Promise.reject(new Error("provider unavailable")), + }); + return withWorkflow( + probe, + Effect.gen(function* () { + const workflow = yield* WorkflowActionsWorkflow; + const request = yield* workflow.dispatch(dispatchInput()); + yield* settle; + + yield* TestClock.adjust("60 seconds"); + yield* settle; + const state = (yield* workflow.snapshot(github)).dispatches.find((item) => item.request.id === request.id); + assert.strictEqual(state?.kind, "locating_timed_out"); + assert.strictEqual(probe.calls.dispatch, 1); + }), + ); +}); + +it.effect("publishes bounded ambiguous candidates for an uncertain mutation without replaying POST", () => { + const problem = { + code: "mutationOutcomeUnknown", + detail: "The provider may have accepted the dispatch.", + title: "Outcome unknown", + type: "about:blank", + }; + const probe = makeApiProbe({ + dispatch: () => ({ error: problem, response: new Response(null, { status: 503 }) }), + runs: () => ({ + repo: apiRepo(), + exhausted: true, + items: [ + run({ id: "candidate-a" }), + run({ id: "candidate-b", head_sha: "head-b" }), + run({ id: "wrong-ref", ref: "release" }), + run({ id: "wrong-actor", actor: "someone-else" }), + run({ id: "wrong-event", event: "push" }), + run({ id: "wrong-workflow", workflow_id: "other.yml" }), + run({ id: "too-late", created_at: "1970-01-01T00:01:01.000Z" }), + ], + }), + }); + return withWorkflow( + probe, + Effect.gen(function* () { + const workflow = yield* WorkflowActionsWorkflow; + const request = yield* workflow.dispatch(dispatchInput()); + yield* settle; + const state = (yield* workflow.snapshot(github)).dispatches.find((item) => item.request.id === request.id); + assert.strictEqual(state?.kind, "uncertain"); + assert.deepStrictEqual( + state?.kind === "uncertain" ? state.candidates.map((candidate) => candidate.id) : [], + ["candidate-a", "candidate-b"], + ); + + yield* TestClock.adjust("60 seconds"); + const readsAtDeadline = probe.calls.runs; + yield* TestClock.adjust("30 seconds"); + assert.strictEqual(probe.calls.runs, readsAtDeadline); + assert.strictEqual(probe.calls.dispatch, 1); + }), + ); +}); + +it.effect("setEnabled(false) releases reads but lets an admitted POST complete exactly once", () => { + const response = Promise.withResolvers(); + const probe = makeApiProbe({ dispatch: () => response.promise }); + return withWorkflow( + probe, + Effect.gen(function* () { + const workflow = yield* WorkflowActionsWorkflow; + const owner = yield* workflow.watchRepository("surface", github, () => {}).pipe(Effect.forkChild); + yield* settle; + const request = yield* workflow.dispatch(dispatchInput()); + yield* settle; + assert.strictEqual(probe.calls.dispatch, 1); + const pending = (yield* workflow.snapshot(github)).dispatches.find((item) => item.request.id === request.id); + assert.strictEqual(pending?.kind, "pending"); + + yield* workflow.setEnabled(false); + yield* TestClock.adjust("2 minutes"); + assert.strictEqual(probe.calls.runs, 1); + response.resolve({ accepted: true, locating_run: false, run: run({ status: "completed", conclusion: "success" }) }); + yield* settle; + + const state = (yield* workflow.snapshot(github)).dispatches.find((item) => item.request.id === request.id); + assert.strictEqual(state?.kind, "succeeded"); + yield* Fiber.interrupt(owner); + }), + ); +}); + +it.effect("loads jobs only for expanded consumers and aborts only after the final consumer collapses", () => { + let aborted = false; + const probe = makeApiProbe({ + jobs: (_call, requestOptions) => { + const response = Promise.withResolvers(); + const signal = requestOptions.signal; + if (signal === undefined) throw new Error("missing generated request abort signal"); + signal.addEventListener("abort", () => { + aborted = true; + response.reject(signal.reason); + }); + return response.promise; + }, + }); + return withWorkflow( + probe, + Effect.gen(function* () { + const workflow = yield* WorkflowActionsWorkflow; + const repoOwner = yield* workflow.watchRepository("surface", github, () => {}).pipe(Effect.forkChild); + yield* settle; + assert.strictEqual(probe.calls.jobs, 0); + + const first = yield* workflow.watchJobs("row-a", github, "run-1").pipe(Effect.forkChild); + const second = yield* workflow.watchJobs("row-b", github, "run-1").pipe(Effect.forkChild); + yield* settle; + assert.strictEqual(probe.calls.jobs, 1); + + yield* Fiber.interrupt(first); + yield* settle; + assert.isFalse(aborted); + yield* Fiber.interrupt(second); + yield* settle; + assert.isTrue(aborted); + yield* Fiber.interrupt(repoOwner); + }), + ); +}); diff --git a/frontend/src/lib/stores/workflow-actions-workflow.ts b/frontend/src/lib/stores/workflow-actions-workflow.ts new file mode 100644 index 0000000000..a8c6047d79 --- /dev/null +++ b/frontend/src/lib/stores/workflow-actions-workflow.ts @@ -0,0 +1,765 @@ +import { + Clock, + Context, + Deferred, + Effect, + FiberMap, + Layer, + Ref, + Schedule, + Semaphore, +} from "effect"; + +import type { components } from "../api/generated/schema.js"; +import { GeneratedApi } from "../api/generated-api.js"; +import { ApiProblemError, TransientTransportError } from "../api/effect-errors.js"; +import { + canonicalProvider, + providerActionsPath, + providerRouteParams, + resolvedPlatformHost, + type ProviderRouteRef, +} from "../api/provider-routes.js"; +import { makeOrderedCommandQueue, type CommandQueueClosed } from "../effect/ordered-command-queue.js"; + +export type WorkflowCatalog = components["schemas"]["WorkflowCatalogResponse"]; +export type WorkflowDefinition = components["schemas"]["WorkflowDefinitionResponse"]; +export type WorkflowDispatchBody = components["schemas"]["WorkflowDispatchBody"]; +export type WorkflowRun = components["schemas"]["WorkflowRunResponse"]; +export type WorkflowRunJob = components["schemas"]["WorkflowRunJobResponse"]; +export type WorkflowRuns = components["schemas"]["WorkflowRunsResponse"]; +export type WorkflowActionsError = ApiProblemError | TransientTransportError | CommandQueueClosed; + +export interface WorkflowDispatchInput { + readonly ref: ProviderRouteRef; + readonly workflowId: string; + readonly expectedDefinitionSha: string; + readonly dispatchRef: string; + readonly inputs: Readonly>; + readonly actor?: string | undefined; +} + +export interface AcceptedWorkflowDispatch { + readonly id: string; + readonly ref: ProviderRouteRef; + readonly workflowId: string; + readonly expectedDefinitionSha: string; + readonly dispatchRef: string; + readonly inputs: Readonly>; + readonly actor?: string | undefined; + readonly acceptedAt: number; +} + +export type WorkflowDispatchState = + | { readonly kind: "pending"; readonly request: AcceptedWorkflowDispatch } + | { readonly kind: "succeeded"; readonly request: AcceptedWorkflowDispatch; readonly run?: WorkflowRun } + | { readonly kind: "locating"; readonly request: AcceptedWorkflowDispatch } + | { readonly kind: "locating_timed_out"; readonly request: AcceptedWorkflowDispatch } + | { readonly kind: "failed"; readonly request: AcceptedWorkflowDispatch; readonly error: WorkflowActionsError } + | { + readonly kind: "uncertain"; + readonly request: AcceptedWorkflowDispatch; + readonly error: WorkflowActionsError; + readonly candidates: readonly WorkflowRun[]; + }; + +export interface WorkflowActionsLoading { + readonly catalog: boolean; + readonly runs: boolean; + readonly jobs: readonly string[]; +} + +export interface WorkflowActionsSnapshot { + readonly ref: ProviderRouteRef; + readonly catalog: WorkflowCatalog | null; + readonly selectedWorkflow: WorkflowDefinition | null; + readonly runs: readonly WorkflowRun[]; + readonly jobs: Readonly>; + readonly loading: WorkflowActionsLoading; + readonly dispatches: readonly WorkflowDispatchState[]; + readonly error: WorkflowActionsError | null; +} + +export type WorkflowActionsObserver = (snapshot: WorkflowActionsSnapshot) => void; + +interface WorkflowActionsWorkflowShape { + readonly watchRepository: ( + owner: string, + ref: ProviderRouteRef, + observer: WorkflowActionsObserver, + ) => Effect.Effect; + readonly watchJobs: (owner: string, ref: ProviderRouteRef, runId: string) => Effect.Effect; + readonly selectWorkflow: (ref: ProviderRouteRef, workflowId: string | null) => Effect.Effect; + readonly dispatch: (input: WorkflowDispatchInput) => Effect.Effect; + readonly snapshot: (ref: ProviderRouteRef) => Effect.Effect; + readonly setEnabled: (enabled: boolean) => Effect.Effect; +} + +export class WorkflowActionsWorkflow extends Context.Service< + WorkflowActionsWorkflow, + WorkflowActionsWorkflowShape +>()("kenn-forge/WorkflowActionsWorkflow") {} + +interface RepositoryOwner { + readonly token: symbol; + readonly observer: WorkflowActionsObserver; +} + +interface RepositoryEntry { + readonly key: string; + readonly ref: ProviderRouteRef; + readonly owners: Map; + snapshot: WorkflowActionsSnapshot; + selectedWorkflowId: string | null; + loopGeneration: number; + loopRunning: boolean; +} + +interface JobDemand { + readonly key: string; + readonly repositoryKey: string; + readonly runId: string; + readonly owners: Map; + generation: number; + running: boolean; +} + +interface DispatchCommand { + readonly request: AcceptedWorkflowDispatch; + readonly admitted: Deferred.Deferred; +} + +const reconciliationWindowMs = 60_000; +const candidateClockSkewMs = 5_000; +const activePollSchedule = Schedule.max([Schedule.spaced("5 seconds"), Schedule.recurs(1)]); +const idlePollSchedule = Schedule.max([Schedule.spaced("30 seconds"), Schedule.recurs(1)]); + +function waitForPoll(active: boolean): Effect.Effect { + return Effect.repeat(Effect.void, active ? activePollSchedule : idlePollSchedule).pipe(Effect.asVoid); +} + +function normalizeRepoPath(repoPath: string): string { + return repoPath + .trim() + .replace(/\\/g, "/") + .replace(/\/{2,}/g, "/") + .replace(/^\/+|\/+$/g, ""); +} + +function normalizeRef(ref: ProviderRouteRef): ProviderRouteRef { + const provider = canonicalProvider(ref.provider); + return { + provider, + platformHost: resolvedPlatformHost(provider, ref.platformHost).toLowerCase(), + owner: ref.owner, + name: ref.name, + repoPath: normalizeRepoPath(ref.repoPath), + }; +} + +export function workflowRepositoryKey(ref: ProviderRouteRef): string { + const normalized = normalizeRef(ref); + const identity = [ + normalized.provider, + normalized.platformHost ?? "", + normalized.owner, + normalized.name, + ] + .map(encodeURIComponent) + .join("\u0000"); + return `${identity}\u0000${normalized.repoPath}`; +} + +function emptySnapshot(ref: ProviderRouteRef): WorkflowActionsSnapshot { + return { + ref, + catalog: null, + selectedWorkflow: null, + runs: [], + jobs: {}, + loading: { catalog: false, runs: false, jobs: [] }, + dispatches: [], + error: null, + }; +} + +function isTerminalRun(run: WorkflowRun): boolean { + const status = run.status.toLowerCase(); + return status === "completed" || status === "cancelled" || status === "failure" || status === "success"; +} + + +function dispatchNeedsPolling(state: WorkflowDispatchState, now: number): boolean { + switch (state.kind) { + case "pending": + return false; + case "succeeded": + return state.run !== undefined && !isTerminalRun(state.run); + case "locating": + return true; + case "uncertain": + return now < state.request.acceptedAt + reconciliationWindowMs; + case "failed": + case "locating_timed_out": + return false; + } +} + +function matchingCandidates(request: AcceptedWorkflowDispatch, runs: readonly WorkflowRun[]): readonly WorkflowRun[] { + const earliest = request.acceptedAt - candidateClockSkewMs; + const latest = request.acceptedAt + reconciliationWindowMs; + return runs.filter((candidate) => { + const createdAt = candidate.created_at === undefined ? Number.NaN : Date.parse(candidate.created_at); + return ( + candidate.workflow_id === request.workflowId && + candidate.event === "workflow_dispatch" && + candidate.ref === request.dispatchRef && + (request.actor === undefined || candidate.actor === request.actor) && + Number.isFinite(createdAt) && + createdAt >= earliest && + createdAt <= latest + ); + }); +} + +function reconcileDispatchStates( + states: readonly WorkflowDispatchState[], + runs: readonly WorkflowRun[], + now: number, +): readonly WorkflowDispatchState[] { + return states.map((state): WorkflowDispatchState => { + switch (state.kind) { + case "pending": + case "failed": + case "locating_timed_out": + return state; + case "succeeded": { + if (state.run === undefined || isTerminalRun(state.run)) return state; + const current = runs.find((candidate) => candidate.id === state.run?.id); + return current === undefined ? state : { ...state, run: current }; + } + case "locating": { + const candidates = matchingCandidates(state.request, runs); + if (candidates.length === 1) return { kind: "succeeded", request: state.request, run: candidates[0] }; + if (now >= state.request.acceptedAt + reconciliationWindowMs) { + return { kind: "locating_timed_out", request: state.request }; + } + return state; + } + case "uncertain": + return { ...state, candidates: matchingCandidates(state.request, runs) }; + } + }); +} + +function isDispatchOutcomeUncertain(error: WorkflowActionsError): boolean { + return ( + error._tag === "TransientTransportError" || + (error._tag === "ApiProblemError" && error.problem.code === "mutationOutcomeUnknown") + ); +} + +export const WorkflowActionsWorkflowLive = Layer.effect(WorkflowActionsWorkflow)( + Effect.gen(function* () { + const api = yield* GeneratedApi; + const scope = yield* Effect.scope; + const registry = yield* Semaphore.make(1); + const requestSequence = yield* Ref.make(0); + const repositoryFibers = yield* FiberMap.make(); + const jobFibers = yield* FiberMap.make(); + const repositories = new Map(); + const ownerRepositories = new Map(); + const jobs = new Map(); + const ownerJobs = new Map(); + let enabled = true; + + function entryFor(ref: ProviderRouteRef): RepositoryEntry { + const normalized = normalizeRef(ref); + const key = workflowRepositoryKey(normalized); + const existing = repositories.get(key); + if (existing !== undefined) return existing; + const created: RepositoryEntry = { + key, + ref: normalized, + owners: new Map(), + snapshot: emptySnapshot(normalized), + selectedWorkflowId: null, + loopGeneration: 0, + loopRunning: false, + }; + repositories.set(key, created); + return created; + } + + function notify(observers: readonly WorkflowActionsObserver[], snapshot: WorkflowActionsSnapshot): Effect.Effect { + return Effect.sync(() => { + for (const observer of observers) { + try { + observer(snapshot); + } catch { + // Presentation cannot change application-owned workflow state. + } + } + }); + } + + const updateSnapshot = Effect.fn("WorkflowActions.updateSnapshot")(function* ( + key: string, + update: (snapshot: WorkflowActionsSnapshot, entry: RepositoryEntry) => WorkflowActionsSnapshot, + ) { + const projection = yield* registry.withPermit( + Effect.sync(() => { + const entry = repositories.get(key); + if (entry === undefined) return undefined; + entry.snapshot = update(entry.snapshot, entry); + return { + snapshot: entry.snapshot, + observers: Array.from(entry.owners.values(), (owner) => owner.observer), + }; + }), + ); + if (projection !== undefined) yield* notify(projection.observers, projection.snapshot); + }); + + function readCatalog(entry: RepositoryEntry) { + return api.execute("GET workflow catalog", (signal) => + api.client.GET(providerActionsPath(entry.ref, "/workflows"), { + params: { path: providerRouteParams(entry.ref) }, + signal, + }), + ); + } + + function readRuns(entry: RepositoryEntry) { + return api.execute("GET workflow runs", (signal) => + api.client.GET(providerActionsPath(entry.ref, "/runs"), { + params: { path: providerRouteParams(entry.ref), query: { per_page: 50 } }, + signal, + }), + ); + } + + function readJobs(entry: RepositoryEntry, runId: string) { + return api.execute("GET workflow run jobs", (signal) => + api.client.GET(providerActionsPath(entry.ref, "/runs/{run_id}/jobs"), { + params: { path: { ...providerRouteParams(entry.ref), run_id: runId } }, + signal, + }), + ); + } + + const repositoryHasDemand = Effect.fn("WorkflowActions.repositoryHasDemand")(function* (key: string) { + const now = yield* Clock.currentTimeMillis; + return yield* registry.withPermit( + Effect.sync(() => { + const entry = repositories.get(key); + if (entry === undefined || !enabled) return false; + return entry.owners.size > 0 || entry.snapshot.dispatches.some((state) => dispatchNeedsPolling(state, now)); + }), + ); + }); + + const loadCatalog = Effect.fn("WorkflowActions.loadCatalog")(function* (entry: RepositoryEntry) { + if (entry.snapshot.catalog !== null) return; + yield* updateSnapshot(entry.key, (snapshot) => ({ + ...snapshot, + loading: { ...snapshot.loading, catalog: true }, + })); + yield* readCatalog(entry).pipe( + Effect.matchEffect({ + onFailure: (error) => + updateSnapshot(entry.key, (snapshot) => ({ + ...snapshot, + error, + loading: { ...snapshot.loading, catalog: false }, + })), + onSuccess: (catalog) => + updateSnapshot(entry.key, (snapshot, current) => ({ + ...snapshot, + catalog, + selectedWorkflow: + catalog.workflows?.find((workflow) => workflow.id === current.selectedWorkflowId) ?? null, + error: null, + loading: { ...snapshot.loading, catalog: false }, + })), + }), + ); + }); + + const loadRuns = Effect.fn("WorkflowActions.loadRuns")(function* (entry: RepositoryEntry) { + yield* updateSnapshot(entry.key, (snapshot) => ({ + ...snapshot, + loading: { ...snapshot.loading, runs: true }, + })); + yield* readRuns(entry).pipe( + Effect.matchEffect({ + onFailure: (error) => + Clock.currentTimeMillis.pipe( + Effect.flatMap((now) => + updateSnapshot(entry.key, (snapshot) => ({ + ...snapshot, + dispatches: reconcileDispatchStates(snapshot.dispatches, snapshot.runs, now), + error, + loading: { ...snapshot.loading, runs: false }, + })), + ), + ), + onSuccess: (response) => + Clock.currentTimeMillis.pipe( + Effect.flatMap((now) => + updateSnapshot(entry.key, (snapshot) => ({ + ...snapshot, + runs: response.items ?? [], + dispatches: reconcileDispatchStates(snapshot.dispatches, response.items ?? [], now), + error: null, + loading: { ...snapshot.loading, runs: false }, + })), + ), + ), + }), + ); + }); + + const runRepositoryLoop = Effect.fn("WorkflowActions.repositoryLoop")(function* ( + key: string, + _generation: number, + ) { + const entry = repositories.get(key); + if (entry === undefined) return; + yield* loadCatalog(entry); + while (yield* repositoryHasDemand(key)) { + yield* loadRuns(entry); + if (!(yield* repositoryHasDemand(key))) return; + const active = yield* Clock.currentTimeMillis.pipe( + Effect.map((now) => + entry.snapshot.runs.some((candidate) => !isTerminalRun(candidate)) || + entry.snapshot.dispatches.some((state) => dispatchNeedsPolling(state, now)), + ), + ); + yield* waitForPoll(active); + } + }); + + function repositoryLoop(key: string, generation: number): Effect.Effect { + return runRepositoryLoop(key, generation).pipe( + Effect.ensuring( + registry.withPermit( + Effect.sync(() => { + const entry = repositories.get(key); + if (entry !== undefined && entry.loopGeneration === generation) entry.loopRunning = false; + }), + ), + ), + ); + } + + const ensureRepositoryLoop = Effect.fn("WorkflowActions.ensureRepositoryLoop")(function* (key: string) { + const generation = yield* registry.withPermit( + Effect.sync(() => { + const entry = repositories.get(key); + if (entry === undefined || entry.loopRunning || !enabled) return undefined; + entry.loopRunning = true; + entry.loopGeneration += 1; + return entry.loopGeneration; + }), + ); + if (generation !== undefined) { + yield* FiberMap.run(repositoryFibers, key, repositoryLoop(key, generation)); + } + }); + + const stopRepositoryLoopIfIdle = Effect.fn("WorkflowActions.stopRepositoryLoopIfIdle")(function* (key: string) { + const now = yield* Clock.currentTimeMillis; + const shouldStop = yield* registry.withPermit( + Effect.sync(() => { + const entry = repositories.get(key); + if (entry === undefined || !entry.loopRunning) return false; + const hasDemand = + enabled && + (entry.owners.size > 0 || entry.snapshot.dispatches.some((state) => dispatchNeedsPolling(state, now))); + if (hasDemand) return false; + entry.loopRunning = false; + entry.loopGeneration += 1; + return true; + }), + ); + if (shouldStop) yield* FiberMap.remove(repositoryFibers, key); + }); + + const dispatchQueue = yield* makeOrderedCommandQueue( + "workflow actions dispatch", + (command) => + Deferred.await(command.admitted).pipe( + Effect.andThen( + api.execute("POST workflow dispatch", (signal) => + api.client.POST(providerActionsPath(command.request.ref, "/workflows/{workflow_id}/dispatch"), { + params: { + path: { + ...providerRouteParams(command.request.ref), + workflow_id: command.request.workflowId, + }, + }, + body: { + expected_definition_sha: command.request.expectedDefinitionSha, + inputs: command.request.inputs, + ref: command.request.dispatchRef, + }, + signal, + }), + ), + ), + Effect.matchEffect({ + onFailure: (error) => + updateSnapshot(workflowRepositoryKey(command.request.ref), (snapshot) => ({ + ...snapshot, + dispatches: snapshot.dispatches.map((state): WorkflowDispatchState => + state.request.id !== command.request.id + ? state + : isDispatchOutcomeUncertain(error) + ? { kind: "uncertain", request: command.request, error, candidates: [] } + : { kind: "failed", request: command.request, error }, + ), + })).pipe( + Effect.andThen( + isDispatchOutcomeUncertain(error) + ? ensureRepositoryLoop(workflowRepositoryKey(command.request.ref)) + : Effect.void, + ), + ), + onSuccess: (response) => + updateSnapshot(workflowRepositoryKey(command.request.ref), (snapshot) => ({ + ...snapshot, + dispatches: snapshot.dispatches.map((state): WorkflowDispatchState => { + if (state.request.id !== command.request.id) return state; + if (response.run !== undefined) { + return { kind: "succeeded", request: command.request, run: response.run }; + } + return { kind: "locating", request: command.request }; + }), + })).pipe( + Effect.andThen( + response.run === undefined || !isTerminalRun(response.run) + ? ensureRepositoryLoop(workflowRepositoryKey(command.request.ref)) + : Effect.void, + ), + ), + }), + ), + ); + + const watchRepository = Effect.fn("WorkflowActions.watchRepository")(function* ( + owner: string, + ref: ProviderRouteRef, + observer: WorkflowActionsObserver, + ) { + const token = Symbol(owner); + const registration = yield* registry.withPermit( + Effect.sync(() => { + if (!enabled) return undefined; + const entry = entryFor(ref); + const previous = ownerRepositories.get(owner); + if (previous !== undefined) repositories.get(previous.key)?.owners.delete(owner); + entry.owners.set(owner, { token, observer }); + ownerRepositories.set(owner, { key: entry.key, token }); + return { entry, previousKey: previous?.key }; + }), + ); + if (registration === undefined) return yield* Effect.never; + observer(registration.entry.snapshot); + if (registration.previousKey !== undefined && registration.previousKey !== registration.entry.key) { + yield* stopRepositoryLoopIfIdle(registration.previousKey); + } + yield* ensureRepositoryLoop(registration.entry.key); + return yield* Effect.never.pipe( + Effect.ensuring( + Effect.gen(function* () { + const releasedKey = yield* registry.withPermit( + Effect.sync(() => { + const current = ownerRepositories.get(owner); + if (current?.token !== token) return undefined; + ownerRepositories.delete(owner); + repositories.get(current.key)?.owners.delete(owner); + return current.key; + }), + ); + if (releasedKey !== undefined) yield* stopRepositoryLoopIfIdle(releasedKey); + }), + ), + ); + }); + + const watchJobs = Effect.fn("WorkflowActions.watchJobs")(function* ( + owner: string, + ref: ProviderRouteRef, + runId: string, + ) { + const token = Symbol(owner); + const repository = entryFor(ref); + const key = `${repository.key}\u0000${encodeURIComponent(runId)}`; + const registration = yield* registry.withPermit( + Effect.sync(() => { + if (!enabled) return undefined; + const previous = ownerJobs.get(owner); + if (previous !== undefined) jobs.get(previous.key)?.owners.delete(owner); + const demand = jobs.get(key) ?? { + key, + repositoryKey: repository.key, + runId, + owners: new Map(), + generation: 0, + running: false, + }; + jobs.set(key, demand); + demand.owners.set(owner, token); + ownerJobs.set(owner, { key, token }); + const cached = repository.snapshot.jobs[runId] !== undefined; + if (demand.running || cached) return { demand, start: false, previousKey: previous?.key }; + demand.running = true; + demand.generation += 1; + return { demand, start: true, previousKey: previous?.key }; + }), + ); + if (registration === undefined) return yield* Effect.never; + if (registration.previousKey !== undefined && registration.previousKey !== key) { + const previous = jobs.get(registration.previousKey); + if (previous !== undefined && previous.owners.size === 0 && previous.running) { + previous.running = false; + previous.generation += 1; + yield* FiberMap.remove(jobFibers, previous.key); + } + } + if (registration.start) { + const generation = registration.demand.generation; + const loading = updateSnapshot(repository.key, (snapshot) => ({ + ...snapshot, + loading: { ...snapshot.loading, jobs: [...snapshot.loading.jobs, runId] }, + })); + const read = loading.pipe( + Effect.andThen(readJobs(repository, runId)), + Effect.matchEffect({ + onFailure: (error) => + updateSnapshot(repository.key, (snapshot) => ({ + ...snapshot, + error, + loading: { ...snapshot.loading, jobs: snapshot.loading.jobs.filter((id) => id !== runId) }, + })), + onSuccess: (response) => + updateSnapshot(repository.key, (snapshot) => ({ + ...snapshot, + jobs: { ...snapshot.jobs, [runId]: response.items ?? [] }, + error: null, + loading: { ...snapshot.loading, jobs: snapshot.loading.jobs.filter((id) => id !== runId) }, + })), + }), + Effect.ensuring( + registry.withPermit( + Effect.sync(() => { + const demand = jobs.get(key); + if (demand !== undefined && demand.generation === generation) demand.running = false; + }), + ), + ), + ); + yield* FiberMap.run(jobFibers, key, read); + } + return yield* Effect.never.pipe( + Effect.ensuring( + Effect.gen(function* () { + const stop = yield* registry.withPermit( + Effect.sync(() => { + const current = ownerJobs.get(owner); + if (current?.token !== token) return false; + ownerJobs.delete(owner); + const demand = jobs.get(current.key); + demand?.owners.delete(owner); + if (demand === undefined || demand.owners.size > 0 || !demand.running) return false; + demand.running = false; + demand.generation += 1; + return true; + }), + ); + if (stop) yield* FiberMap.remove(jobFibers, key); + }), + ), + ); + }); + + const selectWorkflow = Effect.fn("WorkflowActions.selectWorkflow")(function* ( + ref: ProviderRouteRef, + workflowId: string | null, + ) { + const entry = entryFor(ref); + yield* updateSnapshot(entry.key, (snapshot, current) => { + current.selectedWorkflowId = workflowId; + return { + ...snapshot, + selectedWorkflow: snapshot.catalog?.workflows?.find((workflow) => workflow.id === workflowId) ?? null, + }; + }); + }); + + const dispatch = Effect.fn("WorkflowActions.dispatch")(function* (input: WorkflowDispatchInput) { + const acceptedAt = yield* Clock.currentTimeMillis; + const sequence = yield* Ref.updateAndGet(requestSequence, (current) => current + 1); + const request: AcceptedWorkflowDispatch = { + id: `workflow-dispatch-${sequence}`, + ref: normalizeRef(input.ref), + workflowId: input.workflowId, + expectedDefinitionSha: input.expectedDefinitionSha, + dispatchRef: input.dispatchRef, + inputs: { ...input.inputs }, + ...(input.actor !== undefined && { actor: input.actor }), + acceptedAt, + }; + const entry = entryFor(request.ref); + const admitted = yield* Deferred.make(); + const acknowledgement = yield* dispatchQueue.accept({ request, admitted }); + yield* updateSnapshot(entry.key, (snapshot) => ({ + ...snapshot, + dispatches: [...snapshot.dispatches, { kind: "pending", request }], + })); + yield* Deferred.succeed(admitted, undefined); + yield* Effect.forkIn(acknowledgement.pipe(Effect.catch(() => Effect.void)), scope); + return request; + }); + + const snapshot = Effect.fn("WorkflowActions.snapshot")(function* (ref: ProviderRouteRef) { + return yield* registry.withPermit(Effect.sync(() => entryFor(ref).snapshot)); + }); + + const setEnabled = Effect.fn("WorkflowActions.setEnabled")(function* (nextEnabled: boolean) { + const shouldClear = yield* registry.withPermit( + Effect.sync(() => { + enabled = nextEnabled; + if (nextEnabled) return false; + ownerRepositories.clear(); + ownerJobs.clear(); + for (const entry of repositories.values()) { + entry.owners.clear(); + entry.loopRunning = false; + entry.loopGeneration += 1; + } + for (const demand of jobs.values()) { + demand.owners.clear(); + demand.running = false; + demand.generation += 1; + } + return true; + }), + ); + if (shouldClear) { + yield* FiberMap.clear(repositoryFibers); + yield* FiberMap.clear(jobFibers); + } + }); + + return { + watchRepository, + watchJobs, + selectWorkflow, + dispatch, + snapshot, + setEnabled, + }; + }), +); diff --git a/frontend/src/lib/stores/workflow-actions.svelte.test.ts b/frontend/src/lib/stores/workflow-actions.svelte.test.ts new file mode 100644 index 0000000000..3c296d6458 --- /dev/null +++ b/frontend/src/lib/stores/workflow-actions.svelte.test.ts @@ -0,0 +1,223 @@ +import { Effect, Fiber, Layer, ManagedRuntime } from "effect"; +import type { Effect as EffectType } from "effect/Effect"; +import type { Exit as ExitType } from "effect/Exit"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; + +import type { + AppExecution, + AppRuntime, + AppServices, + CommandRunOptions, + OwnedAppRuntime, +} from "../app/runtime.js"; +import type { components } from "../api/generated/schema.js"; +import { makeGeneratedApiLayer } from "../api/generated-api.js"; +import type { GeneratedClient } from "../api/generated-api.js"; +import type { ProviderRouteRef } from "../api/provider-routes.js"; +import { + WorkflowActionsWorkflow, + WorkflowActionsWorkflowLive, +} from "./workflow-actions-workflow.js"; +import { createWorkflowActionsStore } from "./workflow-actions.svelte.js"; + +type WorkflowRun = components["schemas"]["WorkflowRunResponse"]; + +const ref: ProviderRouteRef = { + provider: "github", + platformHost: "github.com", + owner: "octo", + name: "repo", + repoPath: "octo/repo", +}; + +function repo() { + return { + provider: "github", + platform_host: "github.com", + owner: "octo", + name: "repo", + repo_path: "octo/repo", + }; +} + +function workflow() { + return { + available: true, + definition_sha: "definition-a", + id: "deploy.yml", + inputs: [], + name: "Deploy", + path: ".github/workflows/deploy.yml", + state: "active", + web_url: "https://example.test/workflows/deploy.yml", + }; +} + +function run(): WorkflowRun { + return { + actor: "octocat", + conclusion: "success", + created_at: "2026-08-28T12:00:00Z", + event: "workflow_dispatch", + head_sha: "head-a", + id: "run-1", + name: "Deploy", + ref: "main", + run_number: 12, + status: "completed", + web_url: "https://example.test/runs/1", + workflow_id: "deploy.yml", + }; +} + + +function makeApiProbe() { + const get = vi.fn(async (path: string) => { + if (path.endsWith("/workflows")) { + return { + data: { repo: repo(), environments: [{ name: "production" }], workflows: [workflow()] }, + response: new Response(null, { status: 200 }), + }; + } + if (path.endsWith("/jobs")) { + return { + data: { + repo: repo(), + items: [ + { + id: "job-1", + name: "deploy", + status: "completed", + conclusion: "success", + steps: [], + }, + ], + }, + response: new Response(null, { status: 200 }), + }; + } + return { + data: { repo: repo(), exhausted: true, items: [run()] }, + response: new Response(null, { status: 200 }), + }; + }); + const post = vi.fn(async () => ({ + data: { accepted: true, locating_run: false, run: run() }, + response: new Response(null, { status: 202 }), + })); + const client = { GET: get, POST: post, PUT: vi.fn(), DELETE: vi.fn() } as unknown as GeneratedClient; + return { get, post, client }; +} + +function makeWorkflowRuntime(client: GeneratedClient): OwnedAppRuntime { + const layer = WorkflowActionsWorkflowLive.pipe(Layer.provide(makeGeneratedApiLayer(client))); + const managed = ManagedRuntime.make(layer); + + function runCommand( + program: EffectType, + _options: CommandRunOptions, + ): AppExecution { + // This focused runtime intentionally supplies only the service required by the store under test. + const executable = program as unknown as EffectType; + const fiber = managed.runFork(executable); + const completion = Promise.withResolvers>(); + fiber.addObserver(completion.resolve); + return { + interrupt: () => fiber.interruptUnsafe(), + await: Fiber.await(fiber), + exit: completion.promise, + }; + } + + const runtime: AppRuntime = { + runCommand, + runMicrotask: (callback, options) => + runCommand(Effect.sync(callback), { + ...options, + onFailure: () => {}, + }), + }; + return { ...runtime, disposeEffect: managed.disposeEffect }; +} + +let runtime: OwnedAppRuntime | undefined; + +afterEach(async () => { + if (runtime !== undefined) await Effect.runPromise(runtime.disposeEffect); + runtime = undefined; +}); + +describe("workflow Actions projection store", () => { + it("projects catalog, environments, selection, runs, lazy jobs, loading, and dispatch states", async () => { + const probe = makeApiProbe(); + runtime = makeWorkflowRuntime(probe.client); + const store = createWorkflowActionsStore({ runtime }); + + store.claimRepository("actions-page", ref); + await vi.waitFor(() => expect(store.getCatalog(ref)?.workflows).toHaveLength(1)); + expect(store.getEnvironments(ref)).toEqual([{ name: "production" }]); + expect(store.getRuns(ref).map((item) => item.id)).toEqual(["run-1"]); + expect(store.getLoading(ref)).toEqual({ catalog: false, runs: false, jobs: [] }); + + store.selectWorkflow(ref, "deploy.yml"); + await vi.waitFor(() => expect(store.getSelectedWorkflow(ref)?.name).toBe("Deploy")); + + expect(probe.get.mock.calls.some(([path]) => String(path).endsWith("/jobs"))).toBe(false); + store.expandRun("actions-page:run-1", ref, "run-1"); + await vi.waitFor(() => expect(store.getJobs(ref, "run-1").map((job) => job.id)).toEqual(["job-1"])); + + store.dispatch({ + ref, + workflowId: "deploy.yml", + expectedDefinitionSha: "definition-a", + dispatchRef: "main", + inputs: {}, + actor: "octocat", + }); + await vi.waitFor(() => expect(store.getDispatches(ref).at(-1)?.kind).toBe("succeeded")); + expect(probe.post).toHaveBeenCalledTimes(1); + }); + + it("releases synchronous owner launchers and disables all future reads", async () => { + const probe = makeApiProbe(); + runtime = makeWorkflowRuntime(probe.client); + const store = createWorkflowActionsStore({ runtime }); + + store.claimRepository("actions-page", ref); + await vi.waitFor(() => expect(store.getCatalog(ref)).not.toBeNull()); + store.expandRun("actions-page:run-1", ref, "run-1"); + await vi.waitFor(() => expect(store.getJobs(ref, "run-1")).toHaveLength(1)); + + store.releaseRepository("actions-page"); + store.collapseRun("actions-page:run-1"); + store.setEnabled(false); + expect(store.getCatalog(ref)).toBeNull(); + + const reads = probe.get.mock.calls.length; + store.claimRepository("disabled-surface", ref); + store.expandRun("disabled-row", ref, "run-1"); + expect(probe.get).toHaveBeenCalledTimes(reads); + }); + + it("adopts app-owned accepted dispatch state in a replacement presenter", async () => { + const probe = makeApiProbe(); + runtime = makeWorkflowRuntime(probe.client); + const initiating = createWorkflowActionsStore({ runtime }); + initiating.claimRepository("dialog", ref); + initiating.dispatch({ + ref, + workflowId: "deploy.yml", + expectedDefinitionSha: "definition-a", + dispatchRef: "main", + inputs: {}, + actor: "octocat", + }); + await vi.waitFor(() => expect(initiating.getDispatches(ref).at(-1)?.kind).toBe("succeeded")); + initiating.releaseRepository("dialog"); + + const replacement = createWorkflowActionsStore({ runtime }); + replacement.claimRepository("actions-page", ref); + await vi.waitFor(() => expect(replacement.getDispatches(ref).at(-1)?.kind).toBe("succeeded")); + expect(probe.post).toHaveBeenCalledTimes(1); + }); +}); diff --git a/frontend/src/lib/stores/workflow-actions.svelte.ts b/frontend/src/lib/stores/workflow-actions.svelte.ts new file mode 100644 index 0000000000..14a312f11b --- /dev/null +++ b/frontend/src/lib/stores/workflow-actions.svelte.ts @@ -0,0 +1,213 @@ +import { Effect } from "effect"; + +import type { AppExecution, AppRuntime } from "../app/runtime.js"; +import type { ProviderRouteRef } from "../api/provider-routes.js"; +import { + WorkflowActionsWorkflow, + workflowRepositoryKey, + type WorkflowActionsLoading, + type WorkflowActionsSnapshot, + type WorkflowCatalog, + type WorkflowDefinition, + type WorkflowDispatchInput, + type WorkflowDispatchState, + type WorkflowRun, + type WorkflowRunJob, +} from "./workflow-actions-workflow.js"; + +export interface WorkflowActionsStoreOptions { + readonly runtime: AppRuntime; +} + +type WorkflowEnvironment = NonNullable[number]; +type OwnerExecution = AppExecution; + +export interface WorkflowActionsStore { + readonly claimRepository: (owner: string, ref: ProviderRouteRef) => void; + readonly releaseRepository: (owner: string) => void; + readonly selectWorkflow: (ref: ProviderRouteRef, workflowId: string | null) => void; + readonly expandRun: (owner: string, ref: ProviderRouteRef, runId: string) => void; + readonly collapseRun: (owner: string) => void; + readonly dispatch: (input: WorkflowDispatchInput) => void; + readonly setEnabled: (enabled: boolean) => void; + readonly getSnapshot: (ref: ProviderRouteRef) => WorkflowActionsSnapshot | null; + readonly getCatalog: (ref: ProviderRouteRef) => WorkflowCatalog | null; + readonly getEnvironments: (ref: ProviderRouteRef) => readonly WorkflowEnvironment[]; + readonly getSelectedWorkflow: (ref: ProviderRouteRef) => WorkflowDefinition | null; + readonly getRuns: (ref: ProviderRouteRef) => readonly WorkflowRun[]; + readonly getJobs: (ref: ProviderRouteRef, runId: string) => readonly WorkflowRunJob[]; + readonly getLoading: (ref: ProviderRouteRef) => WorkflowActionsLoading; + readonly getDispatches: (ref: ProviderRouteRef) => readonly WorkflowDispatchState[]; +} + +const notLoading: WorkflowActionsLoading = { catalog: false, runs: false, jobs: [] }; + +export function createWorkflowActionsStore(options: WorkflowActionsStoreOptions): WorkflowActionsStore { + const runtime = options.runtime; + let enabled = true; + let projections = $state.raw>>({}); + const repositoryOwners = new Map(); + const jobOwners = new Map(); + + function project(snapshot: WorkflowActionsSnapshot): void { + projections = { ...projections, [workflowRepositoryKey(snapshot.ref)]: snapshot }; + } + + function runOwner(owner: string, ref: ProviderRouteRef): OwnerExecution { + return runtime.runCommand( + Effect.gen(function* () { + const workflow = yield* WorkflowActionsWorkflow; + return yield* workflow.watchRepository(owner, ref, project); + }), + { + operation: "watch workflow Actions repository", + safeContext: { + provider: ref.provider, + owner: ref.owner, + name: ref.name, + }, + onFailure: () => {}, + }, + ); + } + + function claimRepository(owner: string, ref: ProviderRouteRef): void { + if (!enabled) return; + repositoryOwners.get(owner)?.interrupt(); + repositoryOwners.set(owner, runOwner(owner, ref)); + } + + function releaseRepository(owner: string): void { + repositoryOwners.get(owner)?.interrupt(); + repositoryOwners.delete(owner); + } + + function selectWorkflow(ref: ProviderRouteRef, workflowId: string | null): void { + if (!enabled) return; + runtime.runCommand( + Effect.gen(function* () { + const workflow = yield* WorkflowActionsWorkflow; + yield* workflow.selectWorkflow(ref, workflowId); + }), + { + operation: "select provider workflow", + safeContext: { provider: ref.provider, owner: ref.owner, name: ref.name }, + onFailure: () => {}, + }, + ); + } + + function expandRun(owner: string, ref: ProviderRouteRef, runId: string): void { + if (!enabled) return; + jobOwners.get(owner)?.interrupt(); + const execution = runtime.runCommand( + Effect.gen(function* () { + const workflow = yield* WorkflowActionsWorkflow; + return yield* workflow.watchJobs(owner, ref, runId); + }), + { + operation: "watch workflow run jobs", + safeContext: { provider: ref.provider, owner: ref.owner, name: ref.name, runId }, + onFailure: () => {}, + }, + ); + jobOwners.set(owner, execution); + } + + function collapseRun(owner: string): void { + jobOwners.get(owner)?.interrupt(); + jobOwners.delete(owner); + } + + function dispatch(input: WorkflowDispatchInput): void { + if (!enabled) return; + runtime.runCommand( + Effect.gen(function* () { + const workflow = yield* WorkflowActionsWorkflow; + yield* workflow.dispatch(input); + }), + { + operation: "dispatch provider workflow", + safeContext: { + provider: input.ref.provider, + owner: input.ref.owner, + name: input.ref.name, + workflowId: input.workflowId, + }, + onFailure: () => {}, + }, + ); + } + + function setEnabled(nextEnabled: boolean): void { + enabled = nextEnabled; + if (!nextEnabled) { + for (const execution of repositoryOwners.values()) execution.interrupt(); + for (const execution of jobOwners.values()) execution.interrupt(); + repositoryOwners.clear(); + jobOwners.clear(); + projections = {}; + } + runtime.runCommand( + Effect.gen(function* () { + const workflow = yield* WorkflowActionsWorkflow; + yield* workflow.setEnabled(nextEnabled); + }), + { + operation: nextEnabled ? "enable workflow Actions" : "disable workflow Actions", + safeContext: {}, + onFailure: () => {}, + }, + ); + } + + function getSnapshot(ref: ProviderRouteRef): WorkflowActionsSnapshot | null { + return projections[workflowRepositoryKey(ref)] ?? null; + } + + function getCatalog(ref: ProviderRouteRef): WorkflowCatalog | null { + return getSnapshot(ref)?.catalog ?? null; + } + + function getEnvironments(ref: ProviderRouteRef): readonly WorkflowEnvironment[] { + return getSnapshot(ref)?.catalog?.environments ?? []; + } + + function getSelectedWorkflow(ref: ProviderRouteRef): WorkflowDefinition | null { + return getSnapshot(ref)?.selectedWorkflow ?? null; + } + + function getRuns(ref: ProviderRouteRef): readonly WorkflowRun[] { + return getSnapshot(ref)?.runs ?? []; + } + + function getJobs(ref: ProviderRouteRef, runId: string): readonly WorkflowRunJob[] { + return getSnapshot(ref)?.jobs[runId] ?? []; + } + + function getLoading(ref: ProviderRouteRef): WorkflowActionsLoading { + return getSnapshot(ref)?.loading ?? notLoading; + } + + function getDispatches(ref: ProviderRouteRef): readonly WorkflowDispatchState[] { + return getSnapshot(ref)?.dispatches ?? []; + } + + return { + claimRepository, + releaseRepository, + selectWorkflow, + expandRun, + collapseRun, + dispatch, + setEnabled, + getSnapshot, + getCatalog, + getEnvironments, + getSelectedWorkflow, + getRuns, + getJobs, + getLoading, + getDispatches, + }; +} diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 483d2b15c6..7706e06bf8 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -104,6 +104,7 @@ export type { DetailActivityViewStore } from "./stores/detail-activity-view.svel export type { CollapsedReposStore } from "./stores/collapsedRepos.svelte.js"; export type { SettingsStore } from "./stores/settings.svelte.js"; export type { EventsStore } from "./stores/events.svelte.js"; +export type { WorkflowActionsStore } from "./stores/workflow-actions.svelte.js"; export type { DaemonStore } from "./stores/roborev/daemon.svelte.js"; export type { JobsStore } from "./stores/roborev/jobs.svelte.js"; export type { ReviewStore } from "./stores/roborev/review.svelte.js"; @@ -122,6 +123,7 @@ import type { DetailActivityViewStore } from "./stores/detail-activity-view.svel import type { CollapsedReposStore } from "./stores/collapsedRepos.svelte.js"; import type { SettingsStore } from "./stores/settings.svelte.js"; import type { EventsStore } from "./stores/events.svelte.js"; +import type { WorkflowActionsStore } from "./stores/workflow-actions.svelte.js"; import type { DaemonStore } from "./stores/roborev/daemon.svelte.js"; import type { JobsStore } from "./stores/roborev/jobs.svelte.js"; import type { ReviewStore } from "./stores/roborev/review.svelte.js"; @@ -141,6 +143,7 @@ export interface StoreInstances { collapsedRepos: CollapsedReposStore; settings: SettingsStore; events: EventsStore; + workflowActions: WorkflowActionsStore; roborevDaemon?: DaemonStore; roborevJobs?: JobsStore; roborevReview?: ReviewStore; diff --git a/frontend/vitest.node-files.ts b/frontend/vitest.node-files.ts index d20942aace..c6c4899b63 100644 --- a/frontend/vitest.node-files.ts +++ b/frontend/vitest.node-files.ts @@ -14,6 +14,7 @@ export const nodeUnitTestFiles = [ "src/lib/api/problems.test.ts", "src/lib/api/project-intake.test.ts", "src/lib/api/provider-capabilities.test.ts", + "src/lib/api/provider-routes.test.ts", "src/lib/api/provider-labels.test.ts", "src/lib/api/retry-policy.test.ts", "src/lib/api/runtime.test.ts", @@ -77,6 +78,8 @@ export const nodeUnitTestFiles = [ "src/lib/stores/provider-events-workflow.test.ts", "src/lib/stores/provider-key.test.ts", "src/lib/stores/pulls-workflow.test.ts", + "src/lib/stores/workflow-actions.svelte.test.ts", + "src/lib/stores/workflow-actions-workflow.test.ts", "src/lib/stores/repo-browser-workflow.test.ts", "src/lib/stores/roborev/roborev-workflow.test.ts", "src/lib/stores/session-pane-key.test.ts", From ecb057d1a7aa2f92d60fa35078db4ebe7d06efe9 Mon Sep 17 00:00:00 2001 From: Max Mill Date: Fri, 28 Aug 2026 18:11:21 +0200 Subject: [PATCH 13/41] fix: close workflow actions ownership races Install cleanup and admitted dispatch handoffs without interruption gaps, restart missed shared demand exactly once, and clear interrupted loading projections when Actions is disabled. --- context/frontend-effect.md | 4 + .../stores/workflow-actions-workflow.test.ts | 187 ++++++++ .../lib/stores/workflow-actions-workflow.ts | 411 ++++++++++++------ 3 files changed, 459 insertions(+), 143 deletions(-) diff --git a/context/frontend-effect.md b/context/frontend-effect.md index 61303196e8..e81aca304f 100644 --- a/context/frontend-effect.md +++ b/context/frontend-effect.md @@ -42,6 +42,10 @@ service is the supported tool here. - Use scoped acquisition and finalizers for listeners, streams, readers, abort controllers, timers, presenters, and workflow owners. Teardown must be explicit at the same lifetime boundary that acquired the resource. +- Publish an owner registry entry and install its finalizer in one + uninterruptible acquisition handoff. After an ordered queue admits a + non-idempotent command, pending-state publication and executor release are + likewise one uninterruptible handoff. - Use Effect concurrency, queues, fibers, schedules, and interruption instead of bespoke Promise generations, overlapping timers, or boolean race guards. Preserve latest-wins, single-flight, ordered, or lossless semantics explicitly; diff --git a/frontend/src/lib/stores/workflow-actions-workflow.test.ts b/frontend/src/lib/stores/workflow-actions-workflow.test.ts index 28ad4e7ed0..ea4631adf0 100644 --- a/frontend/src/lib/stores/workflow-actions-workflow.test.ts +++ b/frontend/src/lib/stores/workflow-actions-workflow.test.ts @@ -487,3 +487,190 @@ it.effect("loads jobs only for expanded consumers and aborts only after the fina }), ); }); + +it.effect("cannot interrupt the admitted dispatch handoff between pending publication and POST release", () => { + const probe = makeApiProbe(); + return withWorkflow( + probe, + Effect.gen(function* () { + const workflow = yield* WorkflowActionsWorkflow; + let interruptDispatch: (() => void) | undefined; + const owner = yield* workflow + .watchRepository("handoff-observer", github, (snapshot) => { + if (snapshot.dispatches.some((state) => state.kind === "pending")) interruptDispatch?.(); + }) + .pipe(Effect.forkChild); + yield* settle; + + const dispatch = yield* workflow.dispatch(dispatchInput()).pipe(Effect.forkChild); + interruptDispatch = () => dispatch.interruptUnsafe(); + yield* settle; + + assert.strictEqual(probe.calls.dispatch, 1); + assert.strictEqual((yield* workflow.snapshot(github)).dispatches.at(-1)?.kind, "succeeded"); + yield* Fiber.interrupt(owner); + }), + ); +}); + +it.effect("releases a repository owner interrupted by its initial observer", () => { + const probe = makeApiProbe(); + return withWorkflow( + probe, + Effect.gen(function* () { + const workflow = yield* WorkflowActionsWorkflow; + let interruptOwner: (() => void) | undefined; + const interrupted = yield* workflow + .watchRepository("interrupted-owner", github, () => interruptOwner?.()) + .pipe(Effect.forkChild); + interruptOwner = () => interrupted.interruptUnsafe(); + yield* settle; + + const survivor = yield* workflow.watchRepository("survivor", github, () => {}).pipe(Effect.forkChild); + yield* settle; + yield* Fiber.interrupt(survivor); + const readsAfterRelease = probe.calls.runs; + yield* TestClock.adjust("2 minutes"); + + assert.strictEqual(probe.calls.runs, readsAfterRelease); + }), + ); +}); + +it.effect("releases a jobs owner interrupted while replacing its previous run", () => { + const oldRead = Promise.withResolvers(); + let interruptReplacement: (() => void) | undefined; + const probe = makeApiProbe({ + jobs: (call, requestOptions) => { + if (call > 1) return { repo: apiRepo(), items: [] }; + const signal = requestOptions.signal; + if (signal === undefined) throw new Error("missing generated request abort signal"); + signal.addEventListener("abort", () => { + interruptReplacement?.(); + oldRead.reject(signal.reason); + }); + return oldRead.promise; + }, + }); + return withWorkflow( + probe, + Effect.gen(function* () { + const workflow = yield* WorkflowActionsWorkflow; + const previous = yield* workflow.watchJobs("row", github, "run-old").pipe(Effect.forkChild); + yield* settle; + const replacement = yield* workflow.watchJobs("row", github, "run-new").pipe(Effect.forkChild); + interruptReplacement = () => replacement.interruptUnsafe(); + yield* settle; + + const helper = yield* workflow.watchJobs("helper", github, "run-new").pipe(Effect.forkChild); + yield* settle; + assert.strictEqual(probe.calls.jobs, 2); + yield* Fiber.interrupt(previous); + yield* Fiber.interrupt(helper); + }), + ); +}); + +it.effect("restarts a repository loop when demand arrives as the prior loop exits", () => { + const probe = makeApiProbe({ + dispatch: () => ({ accepted: true, locating_run: true }), + runs: () => ({ repo: apiRepo(), items: [], exhausted: true }), + }); + return withWorkflow( + probe, + Effect.gen(function* () { + const workflow = yield* WorkflowActionsWorkflow; + yield* workflow.dispatch(dispatchInput()); + yield* settle; + + yield* TestClock.adjust("60 seconds"); + const owner = yield* workflow.watchRepository("deadline-owner", github, () => {}).pipe(Effect.forkChild); + yield* settle; + const readsAfterClaim = probe.calls.runs; + yield* TestClock.adjust("30 seconds"); + yield* settle; + + assert.isAbove(probe.calls.runs, readsAfterClaim); + yield* Fiber.interrupt(owner); + }), + ); +}); + +it.effect("clears interrupted catalog, runs, and jobs loading markers when disabled", () => { + const catalogRead = Promise.withResolvers(); + const runsRead = Promise.withResolvers(); + const jobsRead = Promise.withResolvers(); + const probe = makeApiProbe({ + catalog: () => catalogRead.promise, + runs: () => runsRead.promise, + jobs: () => jobsRead.promise, + }); + return withWorkflow( + probe, + Effect.gen(function* () { + const workflow = yield* WorkflowActionsWorkflow; + const repositoryOwner = yield* workflow.watchRepository("surface", github, () => {}).pipe(Effect.forkChild); + const jobsOwner = yield* workflow.watchJobs("row", github, "run-1").pipe(Effect.forkChild); + yield* settle; + catalogRead.resolve(catalog()); + yield* settle; + assert.deepStrictEqual((yield* workflow.snapshot(github)).loading, { + catalog: false, + runs: true, + jobs: ["run-1"], + }); + + yield* workflow.setEnabled(false); + assert.deepStrictEqual((yield* workflow.snapshot(github)).loading, { + catalog: false, + runs: false, + jobs: [], + }); + yield* Fiber.interrupt(repositoryOwner); + yield* Fiber.interrupt(jobsOwner); + }), + ); +}); + +it.effect("starts one replacement jobs read for a consumer that joined the failing in-flight read", () => { + const firstRead = Promise.withResolvers(); + const probe = makeApiProbe({ + jobs: (call) => + call === 1 + ? firstRead.promise + : { + repo: apiRepo(), + items: [ + { + id: "job-recovered", + name: "recovered", + status: "completed", + conclusion: "success", + steps: [], + }, + ], + }, + }); + return withWorkflow( + probe, + Effect.gen(function* () { + const workflow = yield* WorkflowActionsWorkflow; + const first = yield* workflow.watchJobs("row-a", github, "run-1").pipe(Effect.forkChild); + yield* settle; + const joined = yield* workflow.watchJobs("row-b", github, "run-1").pipe(Effect.forkChild); + yield* settle; + firstRead.reject(new Error("first read failed")); + yield* settle; + + assert.strictEqual(probe.calls.jobs, 2); + assert.deepStrictEqual( + (yield* workflow.snapshot(github)).jobs["run-1"]?.map((job) => job.id), + ["job-recovered"], + ); + yield* TestClock.adjust("1 minute"); + assert.strictEqual(probe.calls.jobs, 2); + yield* Fiber.interrupt(first); + yield* Fiber.interrupt(joined); + }), + ); +}); diff --git a/frontend/src/lib/stores/workflow-actions-workflow.ts b/frontend/src/lib/stores/workflow-actions-workflow.ts index a8c6047d79..b4cd8652dd 100644 --- a/frontend/src/lib/stores/workflow-actions-workflow.ts +++ b/frontend/src/lib/stores/workflow-actions-workflow.ts @@ -122,6 +122,7 @@ interface JobDemand { readonly owners: Map; generation: number; running: boolean; + restartAfterFailure: boolean; } interface DispatchCommand { @@ -440,34 +441,55 @@ export const WorkflowActionsWorkflowLive = Layer.effect(WorkflowActionsWorkflow) } }); - function repositoryLoop(key: string, generation: number): Effect.Effect { - return runRepositoryLoop(key, generation).pipe( - Effect.ensuring( - registry.withPermit( + const ensureRepositoryLoop = Effect.fn("WorkflowActions.ensureRepositoryLoop")((key: string) => + Effect.uninterruptible( + Effect.gen(function* () { + const now = yield* Clock.currentTimeMillis; + const generation = yield* registry.withPermit( Effect.sync(() => { const entry = repositories.get(key); - if (entry !== undefined && entry.loopGeneration === generation) entry.loopRunning = false; + if (entry === undefined || entry.loopRunning || !enabled) return undefined; + const hasDemand = + entry.owners.size > 0 || + entry.snapshot.dispatches.some((state) => dispatchNeedsPolling(state, now)); + if (!hasDemand) return undefined; + entry.loopRunning = true; + entry.loopGeneration += 1; + return entry.loopGeneration; }), - ), + ); + if (generation !== undefined) { + yield* FiberMap.run(repositoryFibers, key, repositoryLoop(key, generation)); + } + }), + ), + ); + + function repositoryLoop(key: string, generation: number): Effect.Effect { + return runRepositoryLoop(key, generation).pipe( + Effect.ensuring( + Effect.gen(function* () { + const now = yield* Clock.currentTimeMillis; + const shouldRestart = yield* registry.withPermit( + Effect.sync(() => { + const entry = repositories.get(key); + if (entry === undefined || entry.loopGeneration !== generation) return false; + entry.loopRunning = false; + const hasDemand = + enabled && + (entry.owners.size > 0 || + entry.snapshot.dispatches.some((state) => dispatchNeedsPolling(state, now))); + return hasDemand; + }), + ); + if (shouldRestart) { + yield* Effect.forkIn(Effect.yieldNow.pipe(Effect.andThen(ensureRepositoryLoop(key))), scope); + } + }), ), ); } - const ensureRepositoryLoop = Effect.fn("WorkflowActions.ensureRepositoryLoop")(function* (key: string) { - const generation = yield* registry.withPermit( - Effect.sync(() => { - const entry = repositories.get(key); - if (entry === undefined || entry.loopRunning || !enabled) return undefined; - entry.loopRunning = true; - entry.loopGeneration += 1; - return entry.loopGeneration; - }), - ); - if (generation !== undefined) { - yield* FiberMap.run(repositoryFibers, key, repositoryLoop(key, generation)); - } - }); - const stopRepositoryLoopIfIdle = Effect.fn("WorkflowActions.stopRepositoryLoopIfIdle")(function* (key: string) { const now = yield* Clock.currentTimeMillis; const shouldStop = yield* registry.withPermit( @@ -547,140 +569,235 @@ export const WorkflowActionsWorkflowLive = Layer.effect(WorkflowActionsWorkflow) ), ); + const releaseRepositoryOwner = Effect.fn("WorkflowActions.releaseRepositoryOwner")(function* ( + owner: string, + token: symbol, + previousKey: string | undefined, + ) { + const releasedKey = yield* registry.withPermit( + Effect.sync(() => { + const current = ownerRepositories.get(owner); + if (current?.token !== token) return undefined; + ownerRepositories.delete(owner); + repositories.get(current.key)?.owners.delete(owner); + return current.key; + }), + ); + if (releasedKey !== undefined) yield* stopRepositoryLoopIfIdle(releasedKey); + if (previousKey !== undefined && previousKey !== releasedKey) { + yield* stopRepositoryLoopIfIdle(previousKey); + } + }); + const watchRepository = Effect.fn("WorkflowActions.watchRepository")(function* ( owner: string, ref: ProviderRouteRef, observer: WorkflowActionsObserver, ) { - const token = Symbol(owner); - const registration = yield* registry.withPermit( + return yield* Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + const token = Symbol(owner); + const registration = yield* registry.withPermit( + Effect.sync(() => { + if (!enabled) return undefined; + const entry = entryFor(ref); + const previous = ownerRepositories.get(owner); + if (previous !== undefined) repositories.get(previous.key)?.owners.delete(owner); + entry.owners.set(owner, { token, observer }); + ownerRepositories.set(owner, { key: entry.key, token }); + return { entry, previousKey: previous?.key }; + }), + ); + if (registration === undefined) return yield* restore(Effect.never); + + const lifetime = Effect.gen(function* () { + yield* notify([observer], registration.entry.snapshot); + if (registration.previousKey !== undefined && registration.previousKey !== registration.entry.key) { + yield* stopRepositoryLoopIfIdle(registration.previousKey); + } + yield* ensureRepositoryLoop(registration.entry.key); + return yield* Effect.never; + }); + return yield* restore(lifetime).pipe( + Effect.ensuring(releaseRepositoryOwner(owner, token, registration.previousKey)), + ); + }), + ); + }); + + const finishJobRead = Effect.fn("WorkflowActions.finishJobRead")(function* ( + key: string, + generation: number, + succeeded: boolean, + ) { + const shouldRestart = yield* registry.withPermit( Effect.sync(() => { - if (!enabled) return undefined; - const entry = entryFor(ref); - const previous = ownerRepositories.get(owner); - if (previous !== undefined) repositories.get(previous.key)?.owners.delete(owner); - entry.owners.set(owner, { token, observer }); - ownerRepositories.set(owner, { key: entry.key, token }); - return { entry, previousKey: previous?.key }; + const demand = jobs.get(key); + if (demand === undefined || demand.generation !== generation) return false; + demand.running = false; + if (succeeded) demand.restartAfterFailure = false; + if ( + succeeded || + !enabled || + demand.owners.size === 0 || + !demand.restartAfterFailure + ) { + return false; + } + demand.restartAfterFailure = false; + return true; }), ); - if (registration === undefined) return yield* Effect.never; - observer(registration.entry.snapshot); - if (registration.previousKey !== undefined && registration.previousKey !== registration.entry.key) { - yield* stopRepositoryLoopIfIdle(registration.previousKey); + if (shouldRestart) { + yield* Effect.forkIn(Effect.yieldNow.pipe(Effect.andThen(startJobRead(key))), scope); } - yield* ensureRepositoryLoop(registration.entry.key); - return yield* Effect.never.pipe( - Effect.ensuring( - Effect.gen(function* () { - const releasedKey = yield* registry.withPermit( - Effect.sync(() => { - const current = ownerRepositories.get(owner); - if (current?.token !== token) return undefined; - ownerRepositories.delete(owner); - repositories.get(current.key)?.owners.delete(owner); - return current.key; - }), - ); - if (releasedKey !== undefined) yield* stopRepositoryLoopIfIdle(releasedKey); - }), - ), + }); + + function jobRead(key: string, generation: number): Effect.Effect { + const demand = jobs.get(key); + const repository = demand === undefined ? undefined : repositories.get(demand.repositoryKey); + if (demand === undefined || repository === undefined) return Effect.void; + let succeeded = false; + return updateSnapshot(repository.key, (snapshot) => ({ + ...snapshot, + loading: { + ...snapshot.loading, + jobs: snapshot.loading.jobs.includes(demand.runId) + ? snapshot.loading.jobs + : [...snapshot.loading.jobs, demand.runId], + }, + })).pipe( + Effect.andThen(readJobs(repository, demand.runId)), + Effect.matchEffect({ + onFailure: (error) => + updateSnapshot(repository.key, (snapshot) => ({ + ...snapshot, + error, + loading: { + ...snapshot.loading, + jobs: snapshot.loading.jobs.filter((id) => id !== demand.runId), + }, + })), + onSuccess: (response) => + Effect.sync(() => { + succeeded = true; + }).pipe( + Effect.andThen( + updateSnapshot(repository.key, (snapshot) => ({ + ...snapshot, + jobs: { ...snapshot.jobs, [demand.runId]: response.items ?? [] }, + error: null, + loading: { + ...snapshot.loading, + jobs: snapshot.loading.jobs.filter((id) => id !== demand.runId), + }, + })), + ), + ), + }), + Effect.ensuring(Effect.suspend(() => finishJobRead(key, generation, succeeded))), + ); + } + + const startJobRead = Effect.fn("WorkflowActions.startJobRead")((key: string) => + Effect.uninterruptible( + Effect.gen(function* () { + const generation = yield* registry.withPermit( + Effect.sync(() => { + const demand = jobs.get(key); + if (demand === undefined || demand.running || demand.owners.size === 0 || !enabled) { + return undefined; + } + const repository = repositories.get(demand.repositoryKey); + if (repository?.snapshot.jobs[demand.runId] !== undefined) return undefined; + demand.running = true; + demand.generation += 1; + return demand.generation; + }), + ); + if (generation !== undefined) yield* FiberMap.run(jobFibers, key, jobRead(key, generation)); + }), + ), + ); + + const stopJobDemandIfIdle = Effect.fn("WorkflowActions.stopJobDemandIfIdle")(function* (key: string) { + const shouldStop = yield* registry.withPermit( + Effect.sync(() => { + const demand = jobs.get(key); + if (demand === undefined || demand.owners.size > 0 || !demand.running) return false; + demand.running = false; + demand.generation += 1; + demand.restartAfterFailure = false; + return true; + }), ); + if (shouldStop) yield* FiberMap.remove(jobFibers, key); }); - const watchJobs = Effect.fn("WorkflowActions.watchJobs")(function* ( + const releaseJobsOwner = Effect.fn("WorkflowActions.releaseJobsOwner")(function* ( owner: string, - ref: ProviderRouteRef, - runId: string, + token: symbol, + previousKey: string | undefined, ) { - const token = Symbol(owner); - const repository = entryFor(ref); - const key = `${repository.key}\u0000${encodeURIComponent(runId)}`; - const registration = yield* registry.withPermit( + const releasedKey = yield* registry.withPermit( Effect.sync(() => { - if (!enabled) return undefined; - const previous = ownerJobs.get(owner); - if (previous !== undefined) jobs.get(previous.key)?.owners.delete(owner); - const demand = jobs.get(key) ?? { - key, - repositoryKey: repository.key, - runId, - owners: new Map(), - generation: 0, - running: false, - }; - jobs.set(key, demand); - demand.owners.set(owner, token); - ownerJobs.set(owner, { key, token }); - const cached = repository.snapshot.jobs[runId] !== undefined; - if (demand.running || cached) return { demand, start: false, previousKey: previous?.key }; - demand.running = true; - demand.generation += 1; - return { demand, start: true, previousKey: previous?.key }; + const current = ownerJobs.get(owner); + if (current?.token !== token) return undefined; + ownerJobs.delete(owner); + jobs.get(current.key)?.owners.delete(owner); + return current.key; }), ); - if (registration === undefined) return yield* Effect.never; - if (registration.previousKey !== undefined && registration.previousKey !== key) { - const previous = jobs.get(registration.previousKey); - if (previous !== undefined && previous.owners.size === 0 && previous.running) { - previous.running = false; - previous.generation += 1; - yield* FiberMap.remove(jobFibers, previous.key); - } - } - if (registration.start) { - const generation = registration.demand.generation; - const loading = updateSnapshot(repository.key, (snapshot) => ({ - ...snapshot, - loading: { ...snapshot.loading, jobs: [...snapshot.loading.jobs, runId] }, - })); - const read = loading.pipe( - Effect.andThen(readJobs(repository, runId)), - Effect.matchEffect({ - onFailure: (error) => - updateSnapshot(repository.key, (snapshot) => ({ - ...snapshot, - error, - loading: { ...snapshot.loading, jobs: snapshot.loading.jobs.filter((id) => id !== runId) }, - })), - onSuccess: (response) => - updateSnapshot(repository.key, (snapshot) => ({ - ...snapshot, - jobs: { ...snapshot.jobs, [runId]: response.items ?? [] }, - error: null, - loading: { ...snapshot.loading, jobs: snapshot.loading.jobs.filter((id) => id !== runId) }, - })), - }), - Effect.ensuring( - registry.withPermit( - Effect.sync(() => { - const demand = jobs.get(key); - if (demand !== undefined && demand.generation === generation) demand.running = false; - }), - ), - ), - ); - yield* FiberMap.run(jobFibers, key, read); + if (releasedKey !== undefined) yield* stopJobDemandIfIdle(releasedKey); + if (previousKey !== undefined && previousKey !== releasedKey) { + yield* stopJobDemandIfIdle(previousKey); } - return yield* Effect.never.pipe( - Effect.ensuring( - Effect.gen(function* () { - const stop = yield* registry.withPermit( - Effect.sync(() => { - const current = ownerJobs.get(owner); - if (current?.token !== token) return false; - ownerJobs.delete(owner); - const demand = jobs.get(current.key); - demand?.owners.delete(owner); - if (demand === undefined || demand.owners.size > 0 || !demand.running) return false; - demand.running = false; - demand.generation += 1; - return true; - }), - ); - if (stop) yield* FiberMap.remove(jobFibers, key); - }), - ), + }); + + const watchJobs = Effect.fn("WorkflowActions.watchJobs")(function* ( + owner: string, + ref: ProviderRouteRef, + runId: string, + ) { + return yield* Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + const token = Symbol(owner); + const repository = entryFor(ref); + const key = `${repository.key}\u0000${encodeURIComponent(runId)}`; + const registration = yield* registry.withPermit( + Effect.sync(() => { + if (!enabled) return undefined; + const previous = ownerJobs.get(owner); + if (previous !== undefined) jobs.get(previous.key)?.owners.delete(owner); + const demand = jobs.get(key) ?? { + key, + repositoryKey: repository.key, + runId, + owners: new Map(), + generation: 0, + running: false, + restartAfterFailure: false, + }; + jobs.set(key, demand); + demand.owners.set(owner, token); + if (demand.running) demand.restartAfterFailure = true; + ownerJobs.set(owner, { key, token }); + return { previousKey: previous?.key }; + }), + ); + if (registration === undefined) return yield* restore(Effect.never); + + const lifetime = Effect.gen(function* () { + if (registration.previousKey !== undefined && registration.previousKey !== key) { + yield* stopJobDemandIfIdle(registration.previousKey); + } + yield* startJobRead(key); + return yield* Effect.never; + }); + return yield* restore(lifetime).pipe( + Effect.ensuring(releaseJobsOwner(owner, token, registration.previousKey)), + ); + }), ); }); @@ -714,12 +831,15 @@ export const WorkflowActionsWorkflowLive = Layer.effect(WorkflowActionsWorkflow) const entry = entryFor(request.ref); const admitted = yield* Deferred.make(); const acknowledgement = yield* dispatchQueue.accept({ request, admitted }); - yield* updateSnapshot(entry.key, (snapshot) => ({ - ...snapshot, - dispatches: [...snapshot.dispatches, { kind: "pending", request }], - })); - yield* Deferred.succeed(admitted, undefined); - yield* Effect.forkIn(acknowledgement.pipe(Effect.catch(() => Effect.void)), scope); + yield* Effect.uninterruptible( + updateSnapshot(entry.key, (snapshot) => ({ + ...snapshot, + dispatches: [...snapshot.dispatches, { kind: "pending", request }], + })).pipe( + Effect.andThen(Deferred.succeed(admitted, undefined)), + Effect.andThen(Effect.forkIn(acknowledgement.pipe(Effect.catch(() => Effect.void)), scope)), + ), + ); return request; }); @@ -738,11 +858,16 @@ export const WorkflowActionsWorkflowLive = Layer.effect(WorkflowActionsWorkflow) entry.owners.clear(); entry.loopRunning = false; entry.loopGeneration += 1; + entry.snapshot = { + ...entry.snapshot, + loading: { catalog: false, runs: false, jobs: [] }, + }; } for (const demand of jobs.values()) { demand.owners.clear(); demand.running = false; demand.generation += 1; + demand.restartAfterFailure = false; } return true; }), From 05981515304b9138c0529dfbd19e8d366aeb0a9d Mon Sep 17 00:00:00 2001 From: Max Mill Date: Fri, 28 Aug 2026 18:26:16 +0200 Subject: [PATCH 14/41] feat: add typed workflow dispatch controls One accessible form renders provider-normalized inputs and one compact run surface reveals jobs while keeping logs and artifacts on the provider. --- .../actions/WorkflowDispatchDialog.svelte | 43 +++++++ .../actions/WorkflowDispatchDialog.test.ts | 45 +++++++ .../actions/WorkflowDispatchForm.svelte | 116 +++++++++++++++++ .../actions/WorkflowDispatchForm.test.ts | 118 ++++++++++++++++++ .../components/actions/WorkflowRunList.svelte | 100 +++++++++++++++ .../actions/WorkflowRunList.test.ts | 51 ++++++++ 6 files changed, 473 insertions(+) create mode 100644 frontend/src/lib/components/actions/WorkflowDispatchDialog.svelte create mode 100644 frontend/src/lib/components/actions/WorkflowDispatchDialog.test.ts create mode 100644 frontend/src/lib/components/actions/WorkflowDispatchForm.svelte create mode 100644 frontend/src/lib/components/actions/WorkflowDispatchForm.test.ts create mode 100644 frontend/src/lib/components/actions/WorkflowRunList.svelte create mode 100644 frontend/src/lib/components/actions/WorkflowRunList.test.ts diff --git a/frontend/src/lib/components/actions/WorkflowDispatchDialog.svelte b/frontend/src/lib/components/actions/WorkflowDispatchDialog.svelte new file mode 100644 index 0000000000..6b6c720e18 --- /dev/null +++ b/frontend/src/lib/components/actions/WorkflowDispatchDialog.svelte @@ -0,0 +1,43 @@ + + + { void close(); }}> + + {#snippet footer()} + {#if state.kind === "idle"} { void close(); }}>Cancel{/if} + {#if state.kind === "conflict"} onreload?.()}>Reload workflows{/if} + {#if state.kind === "succeeded"} { void close(); }}>Close{/if} + {/snippet} + diff --git a/frontend/src/lib/components/actions/WorkflowDispatchDialog.test.ts b/frontend/src/lib/components/actions/WorkflowDispatchDialog.test.ts new file mode 100644 index 0000000000..7b2386d7ba --- /dev/null +++ b/frontend/src/lib/components/actions/WorkflowDispatchDialog.test.ts @@ -0,0 +1,45 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/svelte"; +import { expect, it, vi } from "vitest"; +vi.mock("../../app/runtime-context.js", () => ({ + getAppRuntime: () => ({ + runMicrotask: (work: () => void) => { + queueMicrotask(work); + return { interrupt: () => undefined }; + }, + }), +})); +import WorkflowDispatchDialog from "./WorkflowDispatchDialog.svelte"; + +const workflow = { available: true, definition_sha: "sha", id: "deploy", inputs: [], name: "Deploy", path: "deploy.yml", state: "active", web_url: "https://github.com/actions/deploy" } as const; +const operation = { available: true } as const; + +it("restores trigger focus when canceled before admission", async () => { + const trigger = document.createElement("button"); + document.body.append(trigger); + trigger.focus(); + const onclose = vi.fn(); + render(WorkflowDispatchDialog, { open: true, workflow, environments: [], initialRef: "main", operation, state: { kind: "idle" }, trigger, onsubmit: vi.fn(), onclose }); + await fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + expect(onclose).toHaveBeenCalledOnce(); + await waitFor(() => expect(document.activeElement).toBe(trigger)); +}); + +it("remains open while pending, uncertain, or conflicted and closes after acknowledgement", async () => { + const onclose = vi.fn(); + const base = { open: true, workflow, environments: [], initialRef: "main", operation, trigger: null, onsubmit: vi.fn(), onclose }; + const view = render(WorkflowDispatchDialog, { ...base, state: { kind: "pending" } as const }); + expect(screen.getByRole("dialog")).toBeTruthy(); + expect(screen.queryByRole("button", { name: "Cancel" })).toBeNull(); + + await view.rerender({ ...base, state: { kind: "uncertain", message: "Outcome unknown" } }); + expect(screen.getByRole("dialog")).toBeTruthy(); + expect(screen.queryByRole("button", { name: "Close" })).toBeNull(); + + await view.rerender({ ...base, state: { kind: "conflict" } }); + expect(screen.getByRole("dialog")).toBeTruthy(); + expect(screen.getByRole("button", { name: "Reload workflows" })).toBeTruthy(); + + await view.rerender({ ...base, state: { kind: "succeeded" } }); + await fireEvent.click(screen.getByRole("button", { name: "Close" })); + expect(onclose).toHaveBeenCalledOnce(); +}); diff --git a/frontend/src/lib/components/actions/WorkflowDispatchForm.svelte b/frontend/src/lib/components/actions/WorkflowDispatchForm.svelte new file mode 100644 index 0000000000..ecf394b586 --- /dev/null +++ b/frontend/src/lib/components/actions/WorkflowDispatchForm.svelte @@ -0,0 +1,116 @@ + + + + +{#if presentation.kind === "conflict"} + +{:else} +
{ event.preventDefault(); submit(); }}> +

{workflow.name}

+ + {#if errors.ref}{/if} + + {#each workflow.inputs ?? [] as input (input.name)} +
+ {#if input.type === "boolean"} + { values[input.name] = checked; }} /> + {:else} + + {#if input.type === "choice" || input.type === "environment"} + + {:else} + { const raw = event.currentTarget.value; values[input.name] = input.type === "number" ? (raw === "" ? "" : Number(raw)) : raw; }} aria-invalid={errors[input.name] ? "true" : undefined} /> + {/if} + {/if} + {#if input.description}{input.description}{/if} + {#if errors[input.name]}{/if} +
+ {/each} + + {#if unavailableReason}{/if} + {#if presentation.kind === "uncertain"}{/if} + +
+{/if} + + diff --git a/frontend/src/lib/components/actions/WorkflowDispatchForm.test.ts b/frontend/src/lib/components/actions/WorkflowDispatchForm.test.ts new file mode 100644 index 0000000000..9f47a2b572 --- /dev/null +++ b/frontend/src/lib/components/actions/WorkflowDispatchForm.test.ts @@ -0,0 +1,118 @@ +import { fireEvent, render, screen } from "@testing-library/svelte"; +import { describe, expect, it, vi } from "vitest"; +import type { components } from "../../api/generated/schema.js"; +import type { OperationAvailability } from "../../api/types.js"; +import WorkflowDispatchForm from "./WorkflowDispatchForm.svelte"; + +type Workflow = components["schemas"]["WorkflowDefinitionResponse"]; +type Environment = components["schemas"]["WorkflowEnvironmentResponse"]; + +const environments: Environment[] = [{ name: "staging" }, { name: "production" }]; +const available: OperationAvailability = { available: true }; + +function workflow(inputs: NonNullable = []): Workflow { + return { + available: true, + definition_sha: "definition-1", + id: "deploy.yml", + inputs, + name: "Deploy", + path: ".github/workflows/deploy.yml", + state: "active", + web_url: "https://github.com/acme/app/actions/workflows/deploy.yml", + }; +} + +describe("WorkflowDispatchForm", () => { + it("renders typed defaults and provider option order", () => { + render(WorkflowDispatchForm, { + workflow: workflow([ + { name: "message", type: "string", required: false, has_default: true, default: "hello" }, + { name: "retries", type: "number", required: false, has_default: true, default: 3 }, + { name: "dry_run", type: "boolean", required: false, has_default: true, default: true }, + { name: "region", type: "choice", required: false, has_default: true, default: "eu", options: ["eu", "us"] }, + { name: "target", type: "environment", required: false, has_default: false }, + ]), + environments, + initialRef: "main", + operation: available, + state: { kind: "idle" }, + onsubmit: vi.fn(), + }); + + expect((screen.getByRole("textbox", { name: "message" }) as HTMLInputElement).value).toBe("hello"); + expect((screen.getByRole("spinbutton", { name: "retries" }) as HTMLInputElement).valueAsNumber).toBe(3); + expect((screen.getByRole("checkbox", { name: "dry_run" }) as HTMLInputElement).checked).toBe(true); + expect(screen.getByRole("combobox", { name: "region" }).querySelectorAll("option")).toHaveLength(2); + expect([...screen.getByRole("combobox", { name: "region" }).querySelectorAll("option")].map((option) => option.textContent)).toEqual(["eu", "us"]); + expect([...screen.getByRole("combobox", { name: "target" }).querySelectorAll("option")].map((option) => option.textContent)).toEqual(["Select an environment", "staging", "production"]); + }); + + it("validates required inputs and editable ref, then submits one normalized request", async () => { + const onsubmit = vi.fn(); + render(WorkflowDispatchForm, { + workflow: workflow([ + { name: "version", type: "string", required: true, has_default: false }, + { name: "count", type: "number", required: true, has_default: false }, + { name: "approved", type: "boolean", required: false, has_default: false }, + ]), + environments, + initialRef: "main", + operation: available, + state: { kind: "idle" }, + onsubmit, + }); + + const ref = screen.getByRole("textbox", { name: "Git ref" }); + expect((ref as HTMLInputElement).value).toBe("main"); + await fireEvent.input(ref, { target: { value: "" } }); + await fireEvent.click(screen.getByRole("button", { name: "Run workflow" })); + expect((await screen.findByText("Git ref is required.")).getAttribute("role")).toBe("alert"); + expect(screen.getByText("version is required.")).toBeTruthy(); + expect(screen.getByText("count is required.")).toBeTruthy(); + expect(onsubmit).not.toHaveBeenCalled(); + + await fireEvent.input(ref, { target: { value: " feature/test " } }); + await fireEvent.input(screen.getByRole("textbox", { name: "version" }), { target: { value: " v2 " } }); + await fireEvent.input(screen.getByRole("spinbutton", { name: "count" }), { target: { value: "4" } }); + await fireEvent.click(screen.getByRole("checkbox", { name: "approved" })); + await fireEvent.click(screen.getByRole("button", { name: "Run workflow" })); + expect(onsubmit).toHaveBeenCalledTimes(1); + expect(onsubmit).toHaveBeenCalledWith({ ref: "feature/test", inputs: { version: "v2", count: 4, approved: true } }); + }); + + it("surfaces unavailable, pending, uncertain, and conflict states without duplicate dispatch", async () => { + const onsubmit = vi.fn(); + const props = { + workflow: workflow(), environments, initialRef: "main", onsubmit, + operation: { available: false, unavailable_reason: "No write credential" } as OperationAvailability, + state: { kind: "idle" } as const, + }; + const view = render(WorkflowDispatchForm, props); + expect(screen.getByText("No write credential")).toBeTruthy(); + expect((screen.getByRole("button", { name: "Run workflow" }) as HTMLButtonElement).disabled).toBe(true); + + await view.rerender({ ...props, operation: available, state: { kind: "pending" } }); + expect((screen.getByRole("textbox", { name: "Git ref" }) as HTMLInputElement).disabled).toBe(true); + expect((screen.getByRole("button", { name: "Running workflow…" }) as HTMLButtonElement).disabled).toBe(true); + await fireEvent.click(screen.getByRole("button", { name: "Running workflow…" })); + expect(onsubmit).not.toHaveBeenCalled(); + + await view.rerender({ ...props, operation: available, state: { kind: "uncertain", message: "The provider may have accepted this run. Verify on the provider before trying again." } }); + expect(screen.getByRole("alert").textContent).toContain("may have accepted"); + expect((screen.getByRole("button", { name: "Run workflow" }) as HTMLButtonElement).disabled).toBe(true); + + await view.rerender({ ...props, operation: available, state: { kind: "conflict" } }); + expect(screen.getByRole("alert").textContent).toContain("Workflow definition changed. Reload workflows before running it."); + expect(screen.queryByRole("textbox", { name: "Git ref" })).toBeNull(); + }); + + it("requires explicit confirmation even when the workflow has no inputs", async () => { + const onsubmit = vi.fn(); + render(WorkflowDispatchForm, { workflow: workflow(), environments, initialRef: "release", operation: available, state: { kind: "idle" }, onsubmit }); + expect(screen.getByRole("heading", { name: "Deploy" })).toBeTruthy(); + expect((screen.getByRole("textbox", { name: "Git ref" }) as HTMLInputElement).value).toBe("release"); + expect(screen.getByRole("button", { name: "Run workflow" })).toBeTruthy(); + expect(onsubmit).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/lib/components/actions/WorkflowRunList.svelte b/frontend/src/lib/components/actions/WorkflowRunList.svelte new file mode 100644 index 0000000000..8ae52d6f28 --- /dev/null +++ b/frontend/src/lib/components/actions/WorkflowRunList.svelte @@ -0,0 +1,100 @@ + + +
+ {#each runs as run (run.id)} +
+
+ + {#if run.web_url} + + {/if} +
+ {#if expandedRuns[run.id]} +
+ {#if loadingJobs.includes(run.id)} +

Loading jobs…

+ {:else if (jobs[run.id]?.length ?? 0) === 0} +

No jobs available.

+ {:else} + {#each jobs[run.id] ?? [] as job (job.id)} +
+ + {#if expandedJobs[job.id] && job.steps} +
    + {#each job.steps as step (step.number)}
  1. {step.name}{statusText(step.status, step.conclusion)}
  2. {/each} +
+ {/if} +
+ {/each} + {/if} +
+ {/if} +
+ {/each} +
+ + diff --git a/frontend/src/lib/components/actions/WorkflowRunList.test.ts b/frontend/src/lib/components/actions/WorkflowRunList.test.ts new file mode 100644 index 0000000000..af26a501c4 --- /dev/null +++ b/frontend/src/lib/components/actions/WorkflowRunList.test.ts @@ -0,0 +1,51 @@ +import { fireEvent, render, screen, within } from "@testing-library/svelte"; +import { expect, it, vi } from "vitest"; +import type { components } from "../../api/generated/schema.js"; +import WorkflowRunList from "./WorkflowRunList.svelte"; + +type Run = components["schemas"]["WorkflowRunResponse"]; +type Job = components["schemas"]["WorkflowRunJobResponse"]; + +const runs: Run[] = [{ actor: "octocat", conclusion: "success", created_at: "2026-08-27T12:30:00Z", event: "workflow_dispatch", head_sha: "0123456789abcdef", id: "run-2", name: "Deploy", ref: "main", run_number: 42, status: "completed", updated_at: "2026-08-27T12:32:00Z", web_url: "https://github.com/acme/app/actions/runs/2", workflow_id: "deploy.yml" }]; +const jobs: Record = { "run-2": [{ id: "job-2", name: "Publish", status: "completed", conclusion: "success", steps: [{ number: 2, name: "Upload", status: "completed", conclusion: "success" }, { number: 1, name: "Build", status: "completed", conclusion: "success" }] }] }; + +it("exposes compact textual run data, local time, and secure provider links", () => { + render(WorkflowRunList, { runs, jobs: {}, loadingJobs: [], onexpand: vi.fn(), oncollapse: vi.fn() }); + const row = screen.getByRole("button", { name: /Run 42 Deploy/ }); + expect(row.textContent).toContain("#42"); + expect(row.textContent).toContain("Deploy"); + expect(row.textContent).toContain("main"); + expect(row.textContent).toContain("octocat"); + expect(row.textContent).toContain("completed · success"); + expect(row.textContent).toContain("0123456"); + expect(row.textContent).toContain(new Date("2026-08-27T12:30:00Z").toLocaleString()); + expect(screen.getByRole("link", { name: "Open on GitHub" }).getAttribute("rel")).toBe("noopener"); + expect(screen.getByRole("link", { name: "Open on GitHub" }).getAttribute("target")).toBe("_blank"); +}); + +it("owns one lazy job request per exact expand and collapse transition and preserves provider order", async () => { + const onexpand = vi.fn(); + const oncollapse = vi.fn(); + const view = render(WorkflowRunList, { runs, jobs, loadingJobs: [], onexpand, oncollapse }); + const disclosure = screen.getByRole("button", { name: /Run 42 Deploy/ }); + expect(disclosure.getAttribute("aria-expanded")).toBe("false"); + + await fireEvent.click(disclosure); + expect(onexpand).toHaveBeenCalledTimes(1); + expect(onexpand).toHaveBeenCalledWith("run-2"); + expect(disclosure.getAttribute("aria-expanded")).toBe("true"); + const job = screen.getByRole("button", { name: /Publish/ }); + expect(job.textContent).toContain("completed · success"); + await fireEvent.click(job); + const steps = screen.getByRole("list", { name: "Publish steps" }); + expect(within(steps).getAllByRole("listitem").map((item) => item.textContent)).toEqual([expect.stringContaining("Upload"), expect.stringContaining("Build")]); + + await fireEvent.click(disclosure); + expect(oncollapse).toHaveBeenCalledTimes(1); + expect(oncollapse).toHaveBeenCalledWith("run-2"); + await fireEvent.click(disclosure); + expect(onexpand).toHaveBeenCalledTimes(2); + + await view.rerender({ runs, jobs, loadingJobs: ["run-2"], onexpand, oncollapse }); + expect(screen.getByText("Loading jobs…").getAttribute("role")).toBe("status"); +}); From fcdff6b463af6dbb4cd1167dcc78f4399eb4ee3e Mon Sep 17 00:00:00 2001 From: Max Mill Date: Fri, 28 Aug 2026 18:53:24 +0200 Subject: [PATCH 15/41] fix: harden workflow dispatch admission Preserve definition-scoped drafts, fence duplicate commands, expose accessible validation, and allow only safe provider run links. --- .../actions/WorkflowDispatchDialog.svelte | 21 +++-- .../actions/WorkflowDispatchDialog.test.ts | 31 ++++--- .../actions/WorkflowDispatchForm.svelte | 80 ++++++++++++------- .../actions/WorkflowDispatchForm.test.ts | 64 ++++++++++++--- .../components/actions/WorkflowRunList.svelte | 6 +- .../actions/WorkflowRunList.test.ts | 8 ++ 6 files changed, 151 insertions(+), 59 deletions(-) diff --git a/frontend/src/lib/components/actions/WorkflowDispatchDialog.svelte b/frontend/src/lib/components/actions/WorkflowDispatchDialog.svelte index 6b6c720e18..af046d475e 100644 --- a/frontend/src/lib/components/actions/WorkflowDispatchDialog.svelte +++ b/frontend/src/lib/components/actions/WorkflowDispatchDialog.svelte @@ -19,11 +19,12 @@ trigger?: HTMLElement | null; onsubmit: (request: WorkflowDispatchRequest) => void; onclose: () => void; - onreload?: (() => void) | undefined; + onreload: () => void; } - let { open, workflow, environments, initialRef, operation, state, trigger = null, onsubmit, onclose, onreload = undefined }: Props = $props(); - const canDismiss = $derived(state.kind === "idle" || state.kind === "succeeded"); + let { open, workflow, environments, initialRef, operation, state: presentation, trigger = null, onsubmit, onclose, onreload }: Props = $props(); + let reloadRequested = $state(false); + const canDismiss = $derived(presentation.kind === "idle" || presentation.kind === "succeeded"); async function close(): Promise { if (!canDismiss) return; @@ -31,13 +32,19 @@ await tick(); trigger?.focus(); } + + function reload(): void { + if (reloadRequested || presentation.kind !== "conflict") return; + reloadRequested = true; + onreload(); + } { void close(); }}> - + {#snippet footer()} - {#if state.kind === "idle"} { void close(); }}>Cancel{/if} - {#if state.kind === "conflict"} onreload?.()}>Reload workflows{/if} - {#if state.kind === "succeeded"} { void close(); }}>Close{/if} + {#if presentation.kind === "idle"} { void close(); }}>Cancel{/if} + {#if presentation.kind === "conflict"}Reload workflows{/if} + {#if presentation.kind === "succeeded"} { void close(); }}>Close{/if} {/snippet} diff --git a/frontend/src/lib/components/actions/WorkflowDispatchDialog.test.ts b/frontend/src/lib/components/actions/WorkflowDispatchDialog.test.ts index 7b2386d7ba..5d4110f6ee 100644 --- a/frontend/src/lib/components/actions/WorkflowDispatchDialog.test.ts +++ b/frontend/src/lib/components/actions/WorkflowDispatchDialog.test.ts @@ -18,26 +18,35 @@ it("restores trigger focus when canceled before admission", async () => { document.body.append(trigger); trigger.focus(); const onclose = vi.fn(); - render(WorkflowDispatchDialog, { open: true, workflow, environments: [], initialRef: "main", operation, state: { kind: "idle" }, trigger, onsubmit: vi.fn(), onclose }); + render(WorkflowDispatchDialog, { open: true, workflow, environments: [], initialRef: "main", operation, state: { kind: "idle" }, trigger, onsubmit: vi.fn(), onclose, onreload: vi.fn() }); await fireEvent.click(screen.getByRole("button", { name: "Cancel" })); expect(onclose).toHaveBeenCalledOnce(); await waitFor(() => expect(document.activeElement).toBe(trigger)); }); -it("remains open while pending, uncertain, or conflicted and closes after acknowledgement", async () => { +it.each([ + [{ kind: "pending" } as const], + [{ kind: "uncertain", message: "Outcome unknown" } as const], + [{ kind: "conflict" } as const], +])("refuses Escape and overlay dismissal while state is %s", async (state) => { const onclose = vi.fn(); - const base = { open: true, workflow, environments: [], initialRef: "main", operation, trigger: null, onsubmit: vi.fn(), onclose }; - const view = render(WorkflowDispatchDialog, { ...base, state: { kind: "pending" } as const }); + render(WorkflowDispatchDialog, { open: true, workflow, environments: [], initialRef: "main", operation, state, trigger: null, onsubmit: vi.fn(), onclose, onreload: vi.fn() }); + const dialog = screen.getByRole("dialog"); + await fireEvent.keyDown(window, { key: "Escape" }); + await fireEvent.click(dialog.parentElement as HTMLElement); + expect(onclose).not.toHaveBeenCalled(); expect(screen.getByRole("dialog")).toBeTruthy(); - expect(screen.queryByRole("button", { name: "Cancel" })).toBeNull(); - - await view.rerender({ ...base, state: { kind: "uncertain", message: "Outcome unknown" } }); - expect(screen.getByRole("dialog")).toBeTruthy(); - expect(screen.queryByRole("button", { name: "Close" })).toBeNull(); +}); - await view.rerender({ ...base, state: { kind: "conflict" } }); +it("reloads a conflict exactly once and closes only after acknowledgement", async () => { + const onclose = vi.fn(); + const onreload = vi.fn(); + const base = { open: true, workflow, environments: [], initialRef: "main", operation, trigger: null, onsubmit: vi.fn(), onclose, onreload }; + const view = render(WorkflowDispatchDialog, { ...base, state: { kind: "conflict" } as const }); + const reload = screen.getByRole("button", { name: "Reload workflows" }); + await Promise.all([fireEvent.click(reload), fireEvent.click(reload)]); + expect(onreload).toHaveBeenCalledOnce(); expect(screen.getByRole("dialog")).toBeTruthy(); - expect(screen.getByRole("button", { name: "Reload workflows" })).toBeTruthy(); await view.rerender({ ...base, state: { kind: "succeeded" } }); await fireEvent.click(screen.getByRole("button", { name: "Close" })); diff --git a/frontend/src/lib/components/actions/WorkflowDispatchForm.svelte b/frontend/src/lib/components/actions/WorkflowDispatchForm.svelte index ecf394b586..b7ef0bea16 100644 --- a/frontend/src/lib/components/actions/WorkflowDispatchForm.svelte +++ b/frontend/src/lib/components/actions/WorkflowDispatchForm.svelte @@ -19,7 +19,6 @@ import { untrack } from "svelte"; type Workflow = components["schemas"]["WorkflowDefinitionResponse"]; type Environment = components["schemas"]["WorkflowEnvironmentResponse"]; - type Input = components["schemas"]["WorkflowInputResponse"]; interface Props { workflow: Workflow; @@ -31,39 +30,64 @@ } let { workflow, environments, initialRef, operation, state: presentation, onsubmit }: Props = $props(); - let ref = $state(untrack(() => initialRef)); - let values = $state>(initialValues(untrack(() => workflow.inputs ?? []))); - let submitted = $state(false); - + interface Draft { + readonly ref: string; + readonly values: Readonly>; + } + let drafts = $state>({}); + let submittedKeys = $state>({}); + let admittedKeys = $state>({}); + const draftKey = $derived(`${workflow.id}\u0000${workflow.definition_sha}\u0000${initialRef}`); + const defaultDraft = $derived.by(() => ({ + ref: initialRef, + values: Object.fromEntries( + untrack(() => workflow.inputs ?? []).map((input) => [ + input.name, + input.has_default ? input.default : input.type === "boolean" ? false : "", + ]), + ), + })); + const draft = $derived(drafts[draftKey] ?? defaultDraft); + const submitted = $derived(submittedKeys[draftKey] === true); + const admitted = $derived(admittedKeys[draftKey] === true); const pending = $derived(presentation.kind === "pending"); - const unavailableReason = $derived(operation?.available === false ? (operation.unavailable_reason ?? "Workflow dispatch is unavailable.") : workflow.available ? "" : (workflow.unavailable_reason ?? "This workflow is unavailable.")); - const errors = $derived.by(() => { - if (!submitted) return {} as Record; + const controlsDisabled = $derived(pending || admitted); + const explicitlyUnavailable = $derived(operation?.available === false || workflow.available === false); + const unavailableReason = $derived.by(() => { + if (operation?.available === false) return operation.unavailable_reason?.trim() || "Workflow dispatch is unavailable."; + if (workflow.available === false) return workflow.unavailable_reason?.trim() || "This workflow is unavailable."; + return ""; + }); + const errors = $derived(submitted ? validationErrors() : {}); + + function inputControlId(name: string): string { + return `workflow-input-${name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "input"}`; + } + + function validationErrors(): Record { const next: Record = {}; - if (ref.trim() === "") next.ref = "Git ref is required."; + if (draft.ref.trim() === "") next.ref = "Git ref is required."; for (const input of workflow.inputs ?? []) { - const value = values[input.name]; - if (input.required && (value === "" || value === undefined || value === null)) next[input.name] = `${input.name} is required.`; + const value = draft.values[input.name]; + const missing = value === undefined || value === null || value === "" || (input.type === "string" && String(value).trim() === ""); + if (input.required && missing) next[input.name] = `${input.name} is required.`; } return next; - }); - - function initialValues(inputs: readonly Input[]): Record { - return Object.fromEntries(inputs.map((input) => [input.name, input.has_default ? input.default : input.type === "boolean" ? false : ""])); } function submit(): void { - if (pending || unavailableReason !== "" || presentation.kind !== "idle") return; - submitted = true; + if (pending || admitted || explicitlyUnavailable || presentation.kind !== "idle") return; + submittedKeys[draftKey] = true; + const currentErrors = validationErrors(); + if (Object.keys(currentErrors).length > 0) return; const normalized: Record = {}; for (const input of workflow.inputs ?? []) { - const value = values[input.name]; - if (input.required && (value === "" || value === undefined || value === null)) continue; + const value = draft.values[input.name]; if (!input.required && value === "") continue; normalized[input.name] = input.type === "string" ? String(value).trim() : value; } - if (ref.trim() === "" || Object.keys(errors).length > 0) return; - onsubmit({ ref: ref.trim(), inputs: normalized }); + admittedKeys[draftKey] = true; + onsubmit({ ref: draft.ref.trim(), inputs: normalized }); } @@ -74,33 +98,33 @@

{workflow.name}

{#if errors.ref}{/if} {#each workflow.inputs ?? [] as input (input.name)}
{#if input.type === "boolean"} - { values[input.name] = checked; }} /> + { drafts[draftKey] = { ...draft, values: { ...draft.values, [input.name]: checked } }; }} /> {:else} - + {#if input.type === "choice" || input.type === "environment"} - { drafts[draftKey] = { ...draft, values: { ...draft.values, [input.name]: event.currentTarget.value } }; }} aria-invalid={errors[input.name] ? "true" : undefined} aria-describedby={errors[input.name] ? `${inputControlId(input.name)}-error` : undefined}> {#if input.type === "environment"}{/if} {#each input.type === "choice" ? (input.options ?? []) : environments.map((item) => item.name) as option (option)}{/each} {:else} - { const raw = event.currentTarget.value; values[input.name] = input.type === "number" ? (raw === "" ? "" : Number(raw)) : raw; }} aria-invalid={errors[input.name] ? "true" : undefined} /> + { const raw = event.currentTarget.value; drafts[draftKey] = { ...draft, values: { ...draft.values, [input.name]: input.type === "number" ? (raw === "" ? "" : Number(raw)) : raw } }; }} aria-invalid={errors[input.name] ? "true" : undefined} aria-describedby={errors[input.name] ? `${inputControlId(input.name)}-error` : undefined} /> {/if} {/if} {#if input.description}{input.description}{/if} - {#if errors[input.name]}{/if} + {#if errors[input.name]}{/if}
{/each} {#if unavailableReason}{/if} {#if presentation.kind === "uncertain"}{/if} - + {/if} diff --git a/frontend/src/lib/components/actions/WorkflowDispatchForm.test.ts b/frontend/src/lib/components/actions/WorkflowDispatchForm.test.ts index 9f47a2b572..8074420bf1 100644 --- a/frontend/src/lib/components/actions/WorkflowDispatchForm.test.ts +++ b/frontend/src/lib/components/actions/WorkflowDispatchForm.test.ts @@ -24,28 +24,27 @@ function workflow(inputs: NonNullable = []): Workflow { } describe("WorkflowDispatchForm", () => { - it("renders typed defaults and provider option order", () => { - render(WorkflowDispatchForm, { + it("renders and submits typed defaults in declared control order", async () => { + const onsubmit = vi.fn(); + const { container } = render(WorkflowDispatchForm, { workflow: workflow([ { name: "message", type: "string", required: false, has_default: true, default: "hello" }, { name: "retries", type: "number", required: false, has_default: true, default: 3 }, { name: "dry_run", type: "boolean", required: false, has_default: true, default: true }, { name: "region", type: "choice", required: false, has_default: true, default: "eu", options: ["eu", "us"] }, - { name: "target", type: "environment", required: false, has_default: false }, + { name: "target", type: "environment", required: false, has_default: true, default: "staging" }, ]), - environments, - initialRef: "main", - operation: available, - state: { kind: "idle" }, - onsubmit: vi.fn(), + environments, initialRef: "main", operation: available, state: { kind: "idle" }, onsubmit, }); + expect([...container.querySelectorAll(".field")].map((field) => (field.matches("label") ? field : field.querySelector("label"))?.textContent?.trim())).toEqual(["Git ref *", "message", "retries", "dry_run", "region", "target"]); expect((screen.getByRole("textbox", { name: "message" }) as HTMLInputElement).value).toBe("hello"); expect((screen.getByRole("spinbutton", { name: "retries" }) as HTMLInputElement).valueAsNumber).toBe(3); expect((screen.getByRole("checkbox", { name: "dry_run" }) as HTMLInputElement).checked).toBe(true); - expect(screen.getByRole("combobox", { name: "region" }).querySelectorAll("option")).toHaveLength(2); expect([...screen.getByRole("combobox", { name: "region" }).querySelectorAll("option")].map((option) => option.textContent)).toEqual(["eu", "us"]); expect([...screen.getByRole("combobox", { name: "target" }).querySelectorAll("option")].map((option) => option.textContent)).toEqual(["Select an environment", "staging", "production"]); + await fireEvent.click(screen.getByRole("button", { name: "Run workflow" })); + expect(onsubmit).toHaveBeenCalledWith({ ref: "main", inputs: { message: "hello", retries: 3, dry_run: true, region: "eu", target: "staging" } }); }); it("validates required inputs and editable ref, then submits one normalized request", async () => { @@ -81,6 +80,48 @@ describe("WorkflowDispatchForm", () => { expect(onsubmit).toHaveBeenCalledWith({ ref: "feature/test", inputs: { version: "v2", count: 4, approved: true } }); }); + it("rejects whitespace-only required strings and wires stable inline errors", async () => { + render(WorkflowDispatchForm, { + workflow: workflow([{ name: "release name", type: "string", required: true, has_default: false }]), + environments, initialRef: "main", operation: available, state: { kind: "idle" }, onsubmit: vi.fn(), + }); + const input = screen.getByRole("textbox", { name: "release name" }); + await fireEvent.input(input, { target: { value: " " } }); + await fireEvent.click(screen.getByRole("button", { name: "Run workflow" })); + const error = screen.getByText("release name is required."); + expect(error.id).toBe("workflow-input-release-name-error"); + expect(input.getAttribute("aria-describedby")).toBe(error.id); + }); + + it("preserves edits across presentation updates, resets on definition identity, and latches rapid admission", async () => { + const onsubmit = vi.fn(); + const definition = workflow([{ name: "version", type: "string", required: false, has_default: true, default: "v1" }]); + const props = { workflow: definition, environments, initialRef: "main", operation: available, state: { kind: "idle" } as const, onsubmit }; + const view = render(WorkflowDispatchForm, props); + await fireEvent.input(screen.getByRole("textbox", { name: "Git ref" }), { target: { value: "feature" } }); + await fireEvent.input(screen.getByRole("textbox", { name: "version" }), { target: { value: "v2" } }); + const run = screen.getByRole("button", { name: "Run workflow" }); + await Promise.all([fireEvent.click(run), fireEvent.click(run)]); + expect(onsubmit).toHaveBeenCalledTimes(1); + + await view.rerender({ ...props, state: { kind: "pending" } }); + await view.rerender({ ...props, state: { kind: "idle" } }); + expect((screen.getByRole("textbox", { name: "Git ref" }) as HTMLInputElement).value).toBe("feature"); + expect((screen.getByRole("textbox", { name: "version" }) as HTMLInputElement).value).toBe("v2"); + + await view.rerender({ ...props, workflow: { ...definition, definition_sha: "definition-2" }, initialRef: "release" }); + expect((screen.getByRole("textbox", { name: "Git ref" }) as HTMLInputElement).value).toBe("release"); + expect((screen.getByRole("textbox", { name: "version" }) as HTMLInputElement).value).toBe("v1"); + }); + + it("uses fallback messages for explicit unavailable booleans", async () => { + const props = { workflow: workflow(), environments, initialRef: "main", state: { kind: "idle" } as const, onsubmit: vi.fn() }; + const view = render(WorkflowDispatchForm, { ...props, operation: { available: false, unavailable_reason: "" } }); + expect(screen.getByRole("alert").textContent).toBe("Workflow dispatch is unavailable."); + await view.rerender({ ...props, workflow: { ...workflow(), available: false, unavailable_reason: "" }, operation: available }); + expect(screen.getByRole("alert").textContent).toBe("This workflow is unavailable."); + }); + it("surfaces unavailable, pending, uncertain, and conflict states without duplicate dispatch", async () => { const onsubmit = vi.fn(); const props = { @@ -107,12 +148,13 @@ describe("WorkflowDispatchForm", () => { expect(screen.queryByRole("textbox", { name: "Git ref" })).toBeNull(); }); - it("requires explicit confirmation even when the workflow has no inputs", async () => { + it("requires explicit confirmation and submits an empty input map", async () => { const onsubmit = vi.fn(); render(WorkflowDispatchForm, { workflow: workflow(), environments, initialRef: "release", operation: available, state: { kind: "idle" }, onsubmit }); expect(screen.getByRole("heading", { name: "Deploy" })).toBeTruthy(); expect((screen.getByRole("textbox", { name: "Git ref" }) as HTMLInputElement).value).toBe("release"); - expect(screen.getByRole("button", { name: "Run workflow" })).toBeTruthy(); expect(onsubmit).not.toHaveBeenCalled(); + await fireEvent.click(screen.getByRole("button", { name: "Run workflow" })); + expect(onsubmit).toHaveBeenCalledWith({ ref: "release", inputs: {} }); }); }); diff --git a/frontend/src/lib/components/actions/WorkflowRunList.svelte b/frontend/src/lib/components/actions/WorkflowRunList.svelte index 8ae52d6f28..dd405da051 100644 --- a/frontend/src/lib/components/actions/WorkflowRunList.svelte +++ b/frontend/src/lib/components/actions/WorkflowRunList.svelte @@ -2,6 +2,7 @@ import { Button } from "@kenn-io/kit-ui"; import ChevronRightIcon from "@lucide/svelte/icons/chevron-right"; import ExternalLinkIcon from "@lucide/svelte/icons/external-link"; + import { isSafeExternalHTTPURL } from "../../utils/safe-external-url.js"; import type { components } from "../../api/generated/schema.js"; type Run = components["schemas"]["WorkflowRunResponse"]; @@ -39,6 +40,7 @@
{#each runs as run (run.id)} + {@const safeRunURL = run.web_url && isSafeExternalHTTPURL(run.web_url) ? run.web_url : undefined}
- {#if run.web_url} - + {#if safeRunURL} + {/if}
{#if expandedRuns[run.id]} diff --git a/frontend/src/lib/components/actions/WorkflowRunList.test.ts b/frontend/src/lib/components/actions/WorkflowRunList.test.ts index af26a501c4..d8c55945fc 100644 --- a/frontend/src/lib/components/actions/WorkflowRunList.test.ts +++ b/frontend/src/lib/components/actions/WorkflowRunList.test.ts @@ -23,6 +23,14 @@ it("exposes compact textual run data, local time, and secure provider links", () expect(screen.getByRole("link", { name: "Open on GitHub" }).getAttribute("target")).toBe("_blank"); }); +it("omits unsafe provider links", () => { + render(WorkflowRunList, { + runs: [{ ...runs[0]!, web_url: "javascript:alert(document.domain)" }], + jobs: {}, loadingJobs: [], onexpand: vi.fn(), oncollapse: vi.fn(), + }); + expect(screen.queryByRole("link")).toBeNull(); +}); + it("owns one lazy job request per exact expand and collapse transition and preserves provider order", async () => { const onexpand = vi.fn(); const oncollapse = vi.fn(); From 24788d5111795ad8614ffd49ea5471716f336872 Mon Sep 17 00:00:00 2001 From: Max Mill Date: Fri, 28 Aug 2026 19:07:02 +0200 Subject: [PATCH 16/41] fix: reset workflow admission cycles Refresh typed drafts on definition changes, release acknowledged submissions for a fresh idle cycle, and keep input and reload ownership collision-free. --- .../actions/WorkflowDispatchDialog.svelte | 18 ++++- .../actions/WorkflowDispatchDialog.test.ts | 19 +++++ .../actions/WorkflowDispatchForm.svelte | 77 ++++++++++++++----- .../actions/WorkflowDispatchForm.test.ts | 58 +++++++++++++- 4 files changed, 151 insertions(+), 21 deletions(-) diff --git a/frontend/src/lib/components/actions/WorkflowDispatchDialog.svelte b/frontend/src/lib/components/actions/WorkflowDispatchDialog.svelte index af046d475e..94ae3d1472 100644 --- a/frontend/src/lib/components/actions/WorkflowDispatchDialog.svelte +++ b/frontend/src/lib/components/actions/WorkflowDispatchDialog.svelte @@ -1,5 +1,6 @@ { void close(); }}> - +
+ +
{#snippet footer()} {#if presentation.kind === "idle"} { void close(); }}>Cancel{/if} {#if presentation.kind === "conflict"}Reload workflows{/if} {#if presentation.kind === "succeeded"} { void close(); }}>Close{/if} {/snippet}
+ + diff --git a/frontend/src/lib/components/actions/WorkflowDispatchDialog.test.ts b/frontend/src/lib/components/actions/WorkflowDispatchDialog.test.ts index 5d4110f6ee..8dcf5cde69 100644 --- a/frontend/src/lib/components/actions/WorkflowDispatchDialog.test.ts +++ b/frontend/src/lib/components/actions/WorkflowDispatchDialog.test.ts @@ -52,3 +52,22 @@ it("reloads a conflict exactly once and closes only after acknowledgement", asyn await fireEvent.click(screen.getByRole("button", { name: "Close" })); expect(onclose).toHaveBeenCalledOnce(); }); + +it("allows one reload in each distinct conflict cycle", async () => { + const onreload = vi.fn(); + const base = { open: true, workflow, environments: [], initialRef: "main", operation, trigger: null, onsubmit: vi.fn(), onclose: vi.fn(), onreload }; + const view = render(WorkflowDispatchDialog, { ...base, state: { kind: "conflict" } as const }); + await fireEvent.click(screen.getByRole("button", { name: "Reload workflows" })); + expect(onreload).toHaveBeenCalledTimes(1); + await view.rerender({ ...base, state: { kind: "idle" } }); + await view.rerender({ ...base, state: { kind: "conflict" } }); + await Promise.all([ + fireEvent.click(screen.getByRole("button", { name: "Reload workflows" })), + fireEvent.click(screen.getByRole("button", { name: "Reload workflows" })), + ]); + expect(onreload).toHaveBeenCalledTimes(2); + await view.rerender({ ...base, open: false, state: { kind: "conflict" } }); + await view.rerender({ ...base, state: { kind: "conflict" } }); + await fireEvent.click(screen.getByRole("button", { name: "Reload workflows" })); + expect(onreload).toHaveBeenCalledTimes(3); +}); diff --git a/frontend/src/lib/components/actions/WorkflowDispatchForm.svelte b/frontend/src/lib/components/actions/WorkflowDispatchForm.svelte index b7ef0bea16..cf75fb02ee 100644 --- a/frontend/src/lib/components/actions/WorkflowDispatchForm.svelte +++ b/frontend/src/lib/components/actions/WorkflowDispatchForm.svelte @@ -17,6 +17,7 @@ import type { components } from "../../api/generated/schema.js"; import type { OperationAvailability } from "../../api/types.js"; import { untrack } from "svelte"; + import type { Attachment } from "svelte/attachments"; type Workflow = components["schemas"]["WorkflowDefinitionResponse"]; type Environment = components["schemas"]["WorkflowEnvironmentResponse"]; @@ -36,20 +37,24 @@ } let drafts = $state>({}); let submittedKeys = $state>({}); - let admittedKeys = $state>({}); + let admissions = $state>({}); const draftKey = $derived(`${workflow.id}\u0000${workflow.definition_sha}\u0000${initialRef}`); - const defaultDraft = $derived.by(() => ({ - ref: initialRef, - values: Object.fromEntries( - untrack(() => workflow.inputs ?? []).map((input) => [ - input.name, - input.has_default ? input.default : input.type === "boolean" ? false : "", - ]), - ), - })); + const defaultDraft = $derived.by(() => { + workflow.id; + workflow.definition_sha; + return { + ref: initialRef, + values: Object.fromEntries( + (workflow.inputs ?? []).map((input) => [ + input.name, + input.has_default ? input.default : input.type === "boolean" ? false : "", + ]), + ), + }; + }); const draft = $derived(drafts[draftKey] ?? defaultDraft); const submitted = $derived(submittedKeys[draftKey] === true); - const admitted = $derived(admittedKeys[draftKey] === true); + const admitted = $derived(admissions[draftKey]?.blocked === true); const pending = $derived(presentation.kind === "pending"); const controlsDisabled = $derived(pending || admitted); const explicitlyUnavailable = $derived(operation?.available === false || workflow.available === false); @@ -60,8 +65,22 @@ }); const errors = $derived(submitted ? validationErrors() : {}); - function inputControlId(name: string): string { - return `workflow-input-${name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "input"}`; + function inputControlId(name: string, index: number): string { + return `workflow-input-${name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "input"}-${index}`; + } + + function observePresentation(key: string, kind: WorkflowDispatchPresentationState["kind"]): Attachment { + return () => { + untrack(() => { + const admission = admissions[key]; + if (!admission?.blocked) return; + if (kind !== "idle") { + admissions[key] = { blocked: true, ownerObserved: true }; + } else if (admission.ownerObserved) { + delete admissions[key]; + } + }); + }; } function validationErrors(): Record { @@ -86,11 +105,12 @@ if (!input.required && value === "") continue; normalized[input.name] = input.type === "string" ? String(value).trim() : value; } - admittedKeys[draftKey] = true; + admissions[draftKey] = { blocked: true, ownerObserved: false }; onsubmit({ ref: draft.ref.trim(), inputs: normalized }); } +
{#if presentation.kind === "conflict"} {:else} @@ -102,23 +122,40 @@ {#if errors.ref}{/if} - {#each workflow.inputs ?? [] as input (input.name)} + {#each workflow.inputs ?? [] as input, index (input.name)}
{#if input.type === "boolean"} { drafts[draftKey] = { ...draft, values: { ...draft.values, [input.name]: checked } }; }} /> {:else} - + {#if input.type === "choice" || input.type === "environment"} - { drafts[draftKey] = { ...draft, values: { ...draft.values, [input.name]: event.currentTarget.value } }; }} + aria-invalid={errors[input.name] ? "true" : undefined} + aria-describedby={errors[input.name] ? `${inputControlId(input.name, index)}-error` : undefined} + > {#if input.type === "environment"}{/if} {#each input.type === "choice" ? (input.options ?? []) : environments.map((item) => item.name) as option (option)}{/each} {:else} - { const raw = event.currentTarget.value; drafts[draftKey] = { ...draft, values: { ...draft.values, [input.name]: input.type === "number" ? (raw === "" ? "" : Number(raw)) : raw } }; }} aria-invalid={errors[input.name] ? "true" : undefined} aria-describedby={errors[input.name] ? `${inputControlId(input.name)}-error` : undefined} /> + { const raw = event.currentTarget.value; drafts[draftKey] = { ...draft, values: { ...draft.values, [input.name]: input.type === "number" ? (raw === "" ? "" : Number(raw)) : raw } }; }} + aria-invalid={errors[input.name] ? "true" : undefined} + aria-describedby={errors[input.name] ? `${inputControlId(input.name, index)}-error` : undefined} + /> {/if} {/if} {#if input.description}{input.description}{/if} - {#if errors[input.name]}{/if} + {#if errors[input.name]}{/if}
{/each} @@ -127,8 +164,10 @@ {/if} +
diff --git a/frontend/src/lib/components/actions/ActionsPage.test.ts b/frontend/src/lib/components/actions/ActionsPage.test.ts new file mode 100644 index 0000000000..39a5182a68 --- /dev/null +++ b/frontend/src/lib/components/actions/ActionsPage.test.ts @@ -0,0 +1,221 @@ +import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/svelte"; +import { Effect } from "effect"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { makeAppRuntime, type OwnedAppRuntime } from "../../app/runtime.js"; +import { STORES_KEY } from "../../context.js"; +import { createWorkflowActionsStore } from "../../stores/workflow-actions.svelte.js"; +import { setGlobalRepo } from "../../stores/filter.svelte.js"; +import { + createMockApiFetch, + jsonResponse, + type MockApiHandle, + type MockRouteOverride, +} from "../../../test/mockApiFetch.js"; + +const runtimeHolder = vi.hoisted(() => ({ value: undefined as OwnedAppRuntime | undefined })); +vi.mock("../../app/runtime-context.js", () => ({ + getAppRuntime: () => runtimeHolder.value, +})); + +import ActionsPage from "./ActionsPage.svelte"; + +const capabilities = { + read_repositories: true, + read_merge_requests: true, + read_issues: true, + read_issue_pr_references: true, + read_comments: true, + read_releases: true, + read_ci: true, + read_workflows: true, + read_workflow_runs: true, + workflow_dispatch: true, + read_labels: true, + read_markdown_images: true, + read_authenticated_user: true, + comment_mutation: true, + state_mutation: true, + merge_mutation: true, + label_mutation: true, + assignee_mutation: true, + reviewer_mutation: true, + review_mutation: true, + workflow_approval: true, + ready_for_review: true, + draft_mutation: true, + issue_mutation: true, + review_draft_mutation: false, + review_thread_resolution: false, + review_suggestion_application: false, + read_review_threads: false, + native_multiline_ranges: false, + mutation_head_binding: false, + thread_reply: false, + thread_resolve: false, + supported_review_actions: [], +}; + +const available = { available: true }; + +function repoSummary(name: string, supported = true) { + return { + owner: "acme", + name, + platform_host: "github.com", + default_platform_host: "github.com", + repo: { + provider: "github", + platform_host: "github.com", + owner: "acme", + name, + repo_path: `acme/${name}`, + capabilities: supported + ? capabilities + : { + ...capabilities, + read_workflows: false, + read_workflow_runs: false, + workflow_dispatch: false, + }, + operations: { dispatch_workflow: available }, + }, + operations: { dispatch_workflow: available }, + cached_pr_count: 0, + open_pr_count: 0, + draft_pr_count: 0, + cached_issue_count: 0, + open_issue_count: 0, + active_authors: [], + recent_issues: [], + commit_timeline: [], + releases: [], + }; +} + +function workflowFixtures(): MockRouteOverride { + const summaries = [ + repoSummary("alpha"), + repoSummary("beta"), + repoSummary("legacy", false), + repoSummary("filtered-out"), + ]; + return (request) => { + if (request.method === "GET" && request.url.pathname === "/api/v1/repos/summary") { + return jsonResponse(summaries); + } + const catalog = request.url.pathname.match(/^\/api\/v1\/actions\/github\/acme\/([^/]+)\/workflows$/); + if (request.method === "GET" && catalog) { + const name = catalog[1]!; + return jsonResponse({ + repo: repoSummary(name).repo, + environments: [{ name: "production" }], + workflows: [{ + id: `${name}-deploy.yml`, + name: `${name} deploy`, + path: `.github/workflows/${name}-deploy.yml`, + state: "active", + available: true, + definition_sha: `${name}-definition`, + inputs: [], + web_url: `https://github.com/acme/${name}/actions/workflows/${name}-deploy.yml`, + }], + }); + } + const runs = request.url.pathname.match(/^\/api\/v1\/actions\/github\/acme\/([^/]+)\/runs$/); + if (request.method === "GET" && runs) { + const name = runs[1]!; + return jsonResponse({ + repo: repoSummary(name).repo, + exhausted: true, + items: [{ + actor: "octocat", + conclusion: "success", + created_at: "2026-08-27T12:30:00Z", + event: "workflow_dispatch", + head_sha: "0123456789abcdef", + id: `${name}-run-1`, + name: `${name} deploy`, + ref: "main", + run_number: 7, + status: "completed", + workflow_id: `${name}-deploy.yml`, + }], + }); + } + const jobs = request.url.pathname.match(/^\/api\/v1\/actions\/github\/acme\/([^/]+)\/runs\/([^/]+)\/jobs$/); + if (request.method === "GET" && jobs) { + return jsonResponse({ + repo: repoSummary(jobs[1]!).repo, + items: [{ + id: "job-1", + name: "Publish", + status: "completed", + conclusion: "success", + steps: [], + }], + }); + } + return null; + }; +} + +describe("ActionsPage", () => { + let runtime: OwnedAppRuntime; + let api: MockApiHandle; + let originalFetch: typeof globalThis.fetch; + + beforeEach(() => { + originalFetch = globalThis.fetch; + api = createMockApiFetch([workflowFixtures()]); + globalThis.fetch = api.fetch; + runtime = makeAppRuntime(); + runtimeHolder.value = runtime; + setGlobalRepo("github|github.com/acme/alpha,github|github.com/acme/beta,github|github.com/acme/legacy"); + }); + + afterEach(async () => { + cleanup(); + setGlobalRepo(undefined); + globalThis.fetch = originalFetch; + runtimeHolder.value = undefined; + await Effect.runPromise(runtime.disposeEffect); + }); + + it("filters repository summaries, distinguishes unsupported repos, and demands only the selected capable repo", async () => { + const workflowActions = createWorkflowActionsStore({ runtime }); + render(ActionsPage, { + context: new Map([[STORES_KEY, { workflowActions }]]), + }); + + const rail = await screen.findByRole("navigation", { name: "Actions repositories" }); + expect(within(rail).getByRole("button", { name: /alpha/ }).getAttribute("aria-current")).toBe("true"); + expect(within(rail).getByRole("button", { name: /beta/ })).toBeTruthy(); + expect(within(rail).getByLabelText("acme/legacy does not support workflow Actions")).toBeTruthy(); + expect(screen.queryByText("filtered-out")).toBeNull(); + + await waitFor(() => { + const paths = api.requests.map((request) => request.url.pathname); + expect(paths).toContain("/api/v1/actions/github/acme/alpha/workflows"); + expect(paths).toContain("/api/v1/actions/github/acme/alpha/runs"); + expect(paths.some((path) => path.includes("/legacy/"))).toBe(false); + }); + }); + + it("uses the shared workflow form and lazy run jobs for the selected repository", async () => { + const workflowActions = createWorkflowActionsStore({ runtime }); + render(ActionsPage, { + context: new Map([[STORES_KEY, { workflowActions }]]), + }); + + await fireEvent.click(await screen.findByRole("button", { name: /alpha deploy/ })); + expect(screen.getByRole("textbox", { name: "Git ref" })).toBeTruthy(); + + const run = await screen.findByRole("button", { name: /Run 7 alpha deploy/ }); + await fireEvent.click(run); + expect(await screen.findByRole("button", { name: /Publish/ })).toBeTruthy(); + expect(api.requests.map((request) => request.url.pathname)).toContain( + "/api/v1/actions/github/acme/alpha/runs/alpha-run-1/jobs", + ); + }); +}); diff --git a/frontend/src/lib/components/layout/AppHeader.svelte b/frontend/src/lib/components/layout/AppHeader.svelte index 38aef0e83a..6bb9aecb96 100644 --- a/frontend/src/lib/components/layout/AppHeader.svelte +++ b/frontend/src/lib/components/layout/AppHeader.svelte @@ -104,6 +104,7 @@ | "activity" | "repos" | "docs" + | "actions" | "pulls" | "issues" | "reviews" @@ -114,6 +115,7 @@ { value: "activity", label: "Activity", mode: "activity" }, { value: "repos", label: "Repos", mode: "repos" }, { value: "docs", label: "Docs", mode: "docs" }, + { value: "actions", label: "Actions", mode: "actions" }, { value: "pulls", label: "PRs", mode: "pulls" }, { value: "issues", label: "Issues", mode: "issues" }, { value: "reviews", label: "Reviews", mode: "reviews" }, @@ -222,6 +224,7 @@ const isProviderRepoSelectorPage = $derived( getPage() === "activity" || getPage() === "repos" || + getPage() === "actions" || getPage() === "pulls" || getPage() === "issues" || getPage() === "workspaces" || @@ -261,7 +264,8 @@ const tabs: TopBarTab[] = $derived.by(() => { const entries: TopBarTab[] = modeNavOptions - .filter((option) => settings.isModeVisible(option.mode)) + .filter((option) => settings.isModeVisible(option.mode) + && (option.value !== "actions" || !isEmbedded())) .map(({ value, label }) => { const tab: TopBarTab = { id: value, label }; if (value === "reviews" && reviewsDaemonUnavailable) { @@ -329,6 +333,7 @@ if (getPage() !== "activity") navigate(getLastActivityRoute()); } else if (destination === "repos") navigate("/repos"); + else if (destination === "actions") navigate("/actions"); else if (destination === "docs") { if (currentMode === destination) { lastStickyModeRoutes.set(destination, stickyModeDefaults[destination]); @@ -353,6 +358,7 @@ if (value === "activity") navigateTab("activity"); else if (value === "repos") navigateTab("repos"); else if (value === "docs") navigateTab("docs"); + else if (value === "actions") navigateTab("actions"); else if (value === "pulls") navigateTab("pulls"); else if (value === "issues") navigateTab("issues"); else if (value === "reviews") navigateTab("reviews"); diff --git a/frontend/src/lib/components/layout/AppHeader.test.ts b/frontend/src/lib/components/layout/AppHeader.test.ts index a627736d00..26f57418fb 100644 --- a/frontend/src/lib/components/layout/AppHeader.test.ts +++ b/frontend/src/lib/components/layout/AppHeader.test.ts @@ -7,7 +7,7 @@ const mockedContainerSize = vi.hoisted(() => ({ value: "wide" as "narrow" | "medium" | "wide", })); -type ModeKey = "activity" | "repos" | "docs" | "pulls" | "issues" | "reviews" | "workspaces"; +type ModeKey = "activity" | "repos" | "docs" | "actions" | "pulls" | "issues" | "reviews" | "workspaces"; const mockedReviewsDaemonAvailable = vi.hoisted(() => ({ value: true })); @@ -17,17 +17,14 @@ const mockedSync = vi.hoisted(() => ({ triggerRepoSync: vi.fn((_repo: string) => Promise.resolve()), })); -const mockedModeVisibility = vi.hoisted(() => ({ - value: { - activity: true, - repos: true, - docs: false, - messages: false, - pulls: true, - issues: true, - reviews: true, - workspaces: true, - } as Record, +const mockedSettings = vi.hoisted(() => ({ + value: undefined as + | { + isModeVisible: (mode: ModeKey) => boolean; + getModeVisibility: () => Record; + setModeVisibility: (visibility: Record) => void; + } + | undefined, })); // Prevent RepoTypeahead from making real API calls in the test environment. @@ -57,9 +54,7 @@ vi.mock("../../context.js", async (importOriginal) => { triggerSync: mockedSync.triggerSync, triggerRepoSync: mockedSync.triggerRepoSync, }, - settings: { - isModeVisible: (mode: ModeKey) => mockedModeVisibility.value[mode], - }, + settings: mockedSettings.value, roborevDaemon: { isAvailable: () => mockedReviewsDaemonAvailable.value, }, @@ -72,6 +67,7 @@ import { initTheme, cleanupTheme } from "../../stores/theme.svelte.js"; import { setGlobalRepo } from "../../stores/filter.svelte.js"; import { setSidebarCollapsed } from "../../stores/sidebar.svelte.ts"; import { navigate } from "../../stores/router.svelte.ts"; +import { createSettingsStore } from "../../stores/settings.svelte.js"; import { isPaletteOpen, resetPaletteState } from "../../stores/keyboard/palette-state.svelte.js"; function compiledStyle(source: string, selector: string): CSSStyleDeclaration { @@ -111,10 +107,10 @@ function mockMatchMedia(matches: boolean, listeners?: MediaChangeCallback[]): vo } function showImportedModes(): void { - mockedModeVisibility.value = { - ...mockedModeVisibility.value, + mockedSettings.value?.setModeVisibility({ + ...mockedSettings.value.getModeVisibility(), docs: true, - }; + }); } function expectReservedRepoSelectorSlot(container: HTMLElement): void { @@ -138,15 +134,7 @@ describe("AppHeader", () => { setGlobalRepo(undefined); delete window.__kenn_forge_config; window.__kenn_forge_notify_config_changed?.(); - mockedModeVisibility.value = { - activity: true, - repos: true, - docs: false, - pulls: true, - issues: true, - reviews: true, - workspaces: true, - }; + mockedSettings.value = createSettingsStore(); resetPaletteState(); }); @@ -163,15 +151,7 @@ describe("AppHeader", () => { setGlobalRepo(undefined); delete window.__kenn_forge_config; window.__kenn_forge_notify_config_changed?.(); - mockedModeVisibility.value = { - activity: true, - repos: true, - docs: false, - pulls: true, - issues: true, - reviews: true, - workspaces: true, - }; + mockedSettings.value = undefined; resetPaletteState(); }); @@ -352,7 +332,10 @@ describe("AppHeader", () => { cleanup(); mockedReviewsDaemonAvailable.value = false; - mockedModeVisibility.value = { ...mockedModeVisibility.value, reviews: false }; + mockedSettings.value?.setModeVisibility({ + ...mockedSettings.value.getModeVisibility(), + reviews: false, + }); render(AppHeader); expect(screen.queryByRole("img", { name: "Reviews daemon unavailable" })).toBeNull(); @@ -366,6 +349,44 @@ describe("AppHeader", () => { expect(screen.queryByRole("button", { name: "Board" })).toBeNull(); }); + it("exposes Actions only while the opt-in mode is enabled", async () => { + initTheme(); + const view = render(AppHeader); + expect(screen.queryByRole("button", { name: "Actions" })).toBeNull(); + + mockedSettings.value?.setModeVisibility({ + ...mockedSettings.value.getModeVisibility(), + actions: true, + }); + await waitFor(() => expect(screen.getByRole("button", { name: "Actions" })).toBeTruthy()); + + await fireEvent.click(screen.getByRole("button", { name: "Actions" })); + expect(window.location.pathname).toBe("/actions"); + expect(screen.getByRole("button", { name: "Actions" }).getAttribute("aria-current")).toBe("page"); + + mockedSettings.value?.setModeVisibility({ + ...mockedSettings.value.getModeVisibility(), + actions: false, + }); + await waitFor(() => expect(screen.queryByRole("button", { name: "Actions" })).toBeNull()); + + view.unmount(); + }); + + it("never exposes Actions navigation in an embedded shell", () => { + initTheme(); + mockedSettings.value?.setModeVisibility({ + ...mockedSettings.value.getModeVisibility(), + actions: true, + }); + window.__kenn_forge_config = { embed: {} }; + window.__kenn_forge_notify_config_changed?.(); + + render(AppHeader); + + expect(screen.queryByRole("button", { name: "Actions" })).toBeNull(); + }); + it("marks the Workspaces tab current on terminal routes", () => { // One tabs list drives both the expanded tab row and kit's collapsed // dropdown, so the terminal → workspaces active mapping only needs diff --git a/frontend/src/lib/stores/router.svelte.ts b/frontend/src/lib/stores/router.svelte.ts index 453cdf2091..cfe0e2efda 100644 --- a/frontend/src/lib/stores/router.svelte.ts +++ b/frontend/src/lib/stores/router.svelte.ts @@ -18,6 +18,7 @@ export type EmbedDetailTab = "pr" | "issue" | "reviews"; export type Route = | { page: "activity" } + | { page: "actions" } | { page: "mobile-activity" } | { page: "mobile-pulls" } | { page: "mobile-issues" } @@ -299,6 +300,9 @@ function parseRoute(fullPath: string): Route { }; } } + if (path === "/actions") { + return { page: "actions" }; + } if (path === "/design-system") { return { page: "design-system" }; } diff --git a/frontend/src/lib/stores/router.test.ts b/frontend/src/lib/stores/router.test.ts index 756cdd8e73..dd07b3d9c1 100644 --- a/frontend/src/lib/stores/router.test.ts +++ b/frontend/src/lib/stores/router.test.ts @@ -162,6 +162,14 @@ describe("router basic routes", () => { expect(getPage()).toBe("design-system"); }); + it("parses and navigates to the top-level Actions workspace", () => { + navigate("/actions"); + + expect(getRoute()).toEqual({ page: "actions" }); + expect(getPage()).toBe("actions"); + expect(window.location.pathname + window.location.search).toBe("/actions"); + }); + it("parses /pulls as list view", () => { navigate("/pulls"); expect(getRoute()).toEqual({ page: "pulls", view: "list" }); diff --git a/frontend/src/test/mockApiFetch.ts b/frontend/src/test/mockApiFetch.ts index 5085dffd68..4f52f55254 100644 --- a/frontend/src/test/mockApiFetch.ts +++ b/frontend/src/test/mockApiFetch.ts @@ -22,6 +22,9 @@ const defaultProviderCapabilities = { read_comments: true, read_releases: true, read_ci: true, + read_workflows: true, + read_workflow_runs: true, + workflow_dispatch: true, read_labels: true, read_markdown_images: true, read_authenticated_user: true, @@ -54,6 +57,7 @@ const defaultRepoOperations = { approve_workflow: defaultOperationAvailability, close_issue: defaultOperationAvailability, close_pr: defaultOperationAvailability, + dispatch_workflow: defaultOperationAvailability, mark_draft: defaultOperationAvailability, mark_ready_for_review: defaultOperationAvailability, merge_pr: defaultOperationAvailability, From 3af642957bbe2ae35c79654a2767e5d6cd5e1818 Mon Sep 17 00:00:00 2001 From: Max Mill Date: Fri, 28 Aug 2026 20:04:26 +0200 Subject: [PATCH 19/41] fix: surface degraded Actions reads Keep provider read failures visible beside retained data and preserve full-height medium layouts when the repository rail is absent. --- context/ui-interaction-contracts.md | 3 ++ .../App.workflow-actions.browser.svelte.ts | 25 ++++++++++ .../lib/components/actions/ActionsPage.svelte | 39 ++++++++++++++-- .../components/actions/ActionsPage.test.ts | 46 +++++++++++++++++++ 4 files changed, 109 insertions(+), 4 deletions(-) diff --git a/context/ui-interaction-contracts.md b/context/ui-interaction-contracts.md index 9885fadbe8..8ecd4f8186 100644 --- a/context/ui-interaction-contracts.md +++ b/context/ui-interaction-contracts.md @@ -258,6 +258,9 @@ Persisted controls must state their scope clearly. - Actions derives provider identity from globally filtered repository summaries and requires workflow catalog, run, and dispatch capabilities; unsupported repos stay visible without reads (`frontend/src/lib/components/actions/ActionsPage.svelte::supportsWorkflowActions`). +- Actions snapshot read failures never collapse into successful empty/current data: empty + failures replace the empty state, while retained runs/jobs remain with a visible stale-data alert + (`frontend/src/lib/components/actions/ActionsPage.svelte::workflowReadErrorMessage`). - Server-backed settings belong in the API only when the preference should follow the user/config rather than one browser session. - Detail timelines apply the server-backed entry limit after filtering and grouping, diff --git a/frontend/src/App.workflow-actions.browser.svelte.ts b/frontend/src/App.workflow-actions.browser.svelte.ts index 5ebd48a971..1d629b1058 100644 --- a/frontend/src/App.workflow-actions.browser.svelte.ts +++ b/frontend/src/App.workflow-actions.browser.svelte.ts @@ -225,4 +225,29 @@ describe("opt-in workflow Actions route", () => { expect(document.querySelector(".actions-page")!.scrollWidth).toBeLessThanOrEqual(window.innerWidth); }, WAIT); }); + + it("uses one full-height medium row when a single capable repository needs no rail", async () => { + await page.viewport(760, 800); + const singleRepository: MockRouteOverride = (request) => { + if (request.method === "GET" && request.url.pathname === "/api/v1/repos/summary") { + return jsonResponse([summary("widgets", true)]); + } + return null; + }; + mounted = await mountBrowserApp("/actions", { + overrides: [singleRepository, actionsFixtures()], + }); + + await expect.element(page.getByRole("heading", { name: "Actions" })).toBeVisible(); + await vi.waitFor(() => { + const layout = document.querySelector(".actions-layout")!; + const workflows = document.querySelector(".workflow-catalog")!; + const workspace = document.querySelector(".workflow-workspace")!; + expect(document.querySelector(".repository-rail")).toBeNull(); + expect(getComputedStyle(layout).display).toBe("grid"); + expect(Math.round(workflows.getBoundingClientRect().top)).toBe(Math.round(workspace.getBoundingClientRect().top)); + expect(Math.round(workflows.getBoundingClientRect().bottom)).toBe(Math.round(layout.getBoundingClientRect().bottom)); + expect(Math.round(workspace.getBoundingClientRect().bottom)).toBe(Math.round(layout.getBoundingClientRect().bottom)); + }, WAIT); + }); }); diff --git a/frontend/src/lib/components/actions/ActionsPage.svelte b/frontend/src/lib/components/actions/ActionsPage.svelte index 97e166005e..b91f26ef3c 100644 --- a/frontend/src/lib/components/actions/ActionsPage.svelte +++ b/frontend/src/lib/components/actions/ActionsPage.svelte @@ -23,6 +23,7 @@ import { getGlobalRepo, parseRepoFilterValue } from "../../stores/filter.svelte.js"; import type { WorkflowActionsSnapshot, + WorkflowActionsError, WorkflowDispatchState, } from "../../stores/workflow-actions-workflow.js"; import WorkflowDispatchForm, { @@ -152,6 +153,14 @@ return "The workflow outcome could not be confirmed."; } + function workflowReadErrorMessage(error: WorkflowActionsError): string { + if (error._tag === "ApiProblemError") { + return apiErrorMessage(error.problem, "Workflow data could not be refreshed."); + } + if ("cause" in error && error.cause instanceof Error) return error.cause.message; + return "Workflow data could not be refreshed."; + } + function submitWorkflow(request: WorkflowDispatchRequest): void { if (!selectedRef || !selectedWorkflow) return; workflowActions.dispatch({ @@ -312,9 +321,15 @@ {#if snapshot}{snapshot.runs.length}{/if} + {#if snapshot?.error} + + {/if} {#if snapshot?.loading.runs && snapshot.runs.length === 0}

Loading runs…

- {:else if (snapshot?.runs.length ?? 0) === 0} + {:else if !snapshot?.error && (snapshot?.runs.length ?? 0) === 0}

No recent workflow runs.

{:else if selectedRef && snapshot} { "/api/v1/actions/github/acme/alpha/runs/alpha-run-1/jobs", ); }); + + it("renders an empty runs read failure as an error instead of a successful empty state", async () => { + const runsFailure: MockRouteOverride = (request) => { + if (request.method !== "GET" + || request.url.pathname !== "/api/v1/actions/github/acme/alpha/runs") return null; + return jsonResponse({ + code: "internalError", + detail: "Recent workflow runs could not be loaded.", + status: 500, + }, 500); + }; + api = createMockApiFetch([runsFailure, workflowFixtures()]); + globalThis.fetch = api.fetch; + const workflowActions = createWorkflowActionsStore({ runtime }); + render(ActionsPage, { + context: new Map([[STORES_KEY, { workflowActions }]]), + }); + + expect((await screen.findByRole("alert")).textContent).toContain("Recent workflow runs could not be loaded."); + expect(screen.queryByText("No recent workflow runs.")).toBeNull(); + }); + + it("keeps retained runs visible while surfacing a lazy jobs read failure as degraded", async () => { + const jobsFailure: MockRouteOverride = (request) => { + if (request.method !== "GET" + || request.url.pathname !== "/api/v1/actions/github/acme/alpha/runs/alpha-run-1/jobs") return null; + return jsonResponse({ + code: "internalError", + detail: "Workflow jobs could not be loaded.", + status: 500, + }, 500); + }; + api = createMockApiFetch([jobsFailure, workflowFixtures()]); + globalThis.fetch = api.fetch; + const workflowActions = createWorkflowActionsStore({ runtime }); + render(ActionsPage, { + context: new Map([[STORES_KEY, { workflowActions }]]), + }); + + const run = await screen.findByRole("button", { name: /Run 7 alpha deploy/ }); + await fireEvent.click(run); + + expect((await screen.findByRole("alert")).textContent).toContain("Workflow jobs could not be loaded."); + expect(screen.getByRole("button", { name: /Run 7 alpha deploy/ })).toBe(run); + expect(run.getAttribute("aria-expanded")).toBe("true"); + }); }); From eb4ba9269842da5d7cb24026a96ec0a3abd67437 Mon Sep 17 00:00:00 2001 From: Max Mill Date: Fri, 28 Aug 2026 20:47:33 +0200 Subject: [PATCH 20/41] feat: run provider workflows from pull requests The existing responsive Actions menu now survives merged PRs and opens the same explicit workflow confirmation used by the Actions page. --- ...lDetail.workflow-actions.browser.svelte.ts | 315 +++++++++++++ .../lib/components/detail/PullDetail.svelte | 418 ++++++++++++++---- .../lib/components/detail/PullDetail.test.ts | 292 +++++++++++- 3 files changed, 941 insertions(+), 84 deletions(-) create mode 100644 frontend/src/PullDetail.workflow-actions.browser.svelte.ts diff --git a/frontend/src/PullDetail.workflow-actions.browser.svelte.ts b/frontend/src/PullDetail.workflow-actions.browser.svelte.ts new file mode 100644 index 0000000000..76e3b5fec7 --- /dev/null +++ b/frontend/src/PullDetail.workflow-actions.browser.svelte.ts @@ -0,0 +1,315 @@ +import { cleanup, render } from "vitest-browser-svelte"; +import { Effect } from "effect"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; + +import "./app.css"; +import type { GeneratedClient } from "./lib/api/generated-api.js"; +import type { PullDetail } from "./lib/api/types.js"; +import { type OwnedAppRuntime } from "./lib/app/runtime.js"; +import { ACTIONS_KEY, NAVIGATE_KEY, STORES_KEY, UI_CONFIG_KEY } from "./lib/context.js"; +import PullDetailComponent from "./lib/components/detail/PullDetail.svelte"; +import PullDetailTestHarness from "./lib/components/detail/PullDetailTestHarness.svelte"; +import { createDetailActivityViewStore } from "./lib/stores/detail-activity-view.svelte.js"; +import { createSettingsStore } from "./lib/stores/settings.svelte.js"; +import { makeTestAppRuntime } from "./lib/testing/effect-layers.js"; + +const WAIT = { timeout: 10_000, interval: 50 } as const; + +const workflow = { + available: true, + definition_sha: "release-definition", + id: "release.yml", + inputs: [], + name: "Release", + path: ".github/workflows/release.yml", + state: "active", + web_url: "https://github.com/acme/widgets/actions/workflows/release.yml", +} as const; + +function pullDetail(): PullDetail { + const capabilities = { + read_repositories: true, + read_merge_requests: true, + read_issues: true, + read_issue_pr_references: true, + read_comments: true, + read_releases: true, + read_ci: true, + read_workflows: true, + read_workflow_runs: true, + workflow_dispatch: true, + read_labels: false, + read_markdown_images: true, + read_authenticated_user: true, + comment_mutation: false, + state_mutation: true, + merge_mutation: true, + label_mutation: false, + assignee_mutation: false, + reviewer_mutation: false, + review_mutation: true, + workflow_approval: false, + ready_for_review: false, + draft_mutation: false, + issue_mutation: false, + review_draft_mutation: false, + review_thread_resolution: false, + review_suggestion_application: false, + read_review_threads: false, + native_multiline_ranges: false, + mutation_head_binding: false, + thread_reply: false, + thread_resolve: false, + supported_review_actions: [], + } as const; + const operations = { + close_pr: { available: true }, + dispatch_workflow: { available: true }, + merge_pr: { available: true }, + submit_review: { available: true }, + } as const; + const repo = { + ID: 1, + Owner: "acme", + Name: "widgets", + Host: "github.com", + PlatformHost: "github.com", + Platform: "github", + URL: "https://github.com/acme/widgets", + DefaultBranch: "main", + IsArchived: false, + AllowSquashMerge: true, + AllowMergeCommit: false, + AllowRebaseMerge: false, + capabilities, + operations, + provider: "github", + platform_host: "github.com", + owner: "acme", + name: "widgets", + repo_path: "acme/widgets", + }; + return { + detail_loaded: true, + detail_fetched_at: "2026-08-28T12:00:00Z", + deferred_merge_pending: false, + diff_head_sha: "head", + head_repo_kind: "same_repo", + merge_base_sha: "base", + platform_base_sha: "base", + platform_head_sha: "head", + reviewed_head_sha: "head", + platform_host: "github.com", + repo_owner: "acme", + repo_name: "widgets", + warnings: [], + workflow_approval: { count: 0, required: false, runs: [] }, + workspace: undefined, + worktree_links: [], + repo, + merge_request: { + ID: 1, + RepoID: 1, + PlatformID: 42, + PlatformExternalID: "PR_42", + Number: 42, + URL: "https://github.com/acme/widgets/pull/42", + Title: "Add browser regression coverage", + Author: "marius", + AuthorDisplayName: "Marius", + State: "open", + IsDraft: false, + IsLocked: false, + Body: "Adds browser coverage for provider workflows.", + HeadBranch: "feature/workflow-actions", + BaseBranch: "main", + HeadRepoCloneURL: "https://github.com/acme/widgets.git", + Additions: 12, + Deletions: 2, + CommentCount: 0, + ReviewDecision: "APPROVED", + CIStatus: "success", + CIChecksJSON: "[]", + CIHadPending: false, + CreatedAt: "2026-08-28T10:00:00Z", + UpdatedAt: "2026-08-28T12:00:00Z", + LastActivityAt: "2026-08-28T12:00:00Z", + MergedAt: null, + ClosedAt: null, + MergeableState: "clean", + DetailFetchedAt: "2026-08-28T12:00:00Z", + KanbanStatus: "reviewing", + Starred: false, + labels: [], + }, + events: [], + }; +} + +let runtime: OwnedAppRuntime | null = null; +let catalogClaimed = $state(false); + +function visibleActionsTriggers(): HTMLButtonElement[] { + return Array.from(document.querySelectorAll("button.actions-menu-trigger")).filter((button) => { + if (button.closest("[aria-hidden='true']")) return false; + return getComputedStyle(button).display !== "none"; + }); +} + +function visibleButton(name: string): HTMLButtonElement | null { + return Array.from(document.querySelectorAll("button")).find((button) => + button.textContent?.trim() === name + && !button.closest("[aria-hidden='true']") + && getComputedStyle(button).display !== "none" + && button.offsetParent !== null + ) ?? null; +} + +afterEach(async () => { + cleanup(); + if (runtime) await Effect.runPromise(runtime.disposeEffect); + runtime = null; + catalogClaimed = false; +}); + +describe("PullDetail provider workflow action geometry", () => { + it("uses one Actions trigger while primary pull actions move into that menu only under pressure", async () => { + const detail = pullDetail(); + const apiClient = { + GET: vi.fn(async () => ({ + data: { + AllowSquashMerge: true, + AllowMergeCommit: false, + AllowRebaseMerge: false, + ViewerCanMerge: true, + operations: detail.repo.operations, + }, + })), + POST: vi.fn(async () => ({ data: {} })), + } as unknown as GeneratedClient; + runtime = makeTestAppRuntime(apiClient); + const settings = createSettingsStore(); + settings.setModeVisibility({ ...settings.getModeVisibility(), actions: true }); + settings.setDetailSettings({ initial_timeline_entry_limit: 250 }); + const workflowActions = { + claimRepository: vi.fn(() => { catalogClaimed = true; }), + releaseRepository: vi.fn(() => { catalogClaimed = false; }), + selectWorkflow: vi.fn(), + expandRun: vi.fn(), + collapseRun: vi.fn(), + dispatch: vi.fn(), + setEnabled: vi.fn(), + getSnapshot: vi.fn(() => null), + getCatalog: () => catalogClaimed + ? { repo: detail.repo, environments: [], workflows: [workflow] } + : null, + getEnvironments: () => [], + getSelectedWorkflow: vi.fn(() => null), + getRuns: vi.fn(() => []), + getJobs: vi.fn(() => []), + getLoading: vi.fn(() => ({ catalog: false, runs: false, jobs: [] })), + getDispatches: () => [], + }; + const detailStore = { + loadDetail: vi.fn(), + startDetailPolling: vi.fn(), + stopDetailPolling: vi.fn(), + getDetail: () => detail, + getDetailEnvelopeTick: () => 0, + isDetailLoading: () => false, + getDetailError: () => null, + isDetailSyncing: () => false, + getDetailLoaded: () => true, + updateKanbanState: vi.fn(), + setPullState: vi.fn(), + toggleDetailPRStar: vi.fn(), + updatePRContent: vi.fn(), + refreshPendingCI: vi.fn(), + syncDetailNow: vi.fn(), + refreshDetailOnly: vi.fn(), + approvePull: vi.fn(), + requestPullChanges: vi.fn(), + markPullReady: vi.fn(), + approvePullWorkflows: vi.fn(), + mergePull: vi.fn(), + editComment: vi.fn(), + savePRBodyInBackground: vi.fn(), + setLocalPRBody: vi.fn(), + applyReviewSuggestions: vi.fn(), + }; + const wrapper = document.createElement("div"); + wrapper.style.width = "900px"; + document.body.appendChild(wrapper); + + render(PullDetailTestHarness, { + target: wrapper, + props: { + runtime, + detailProps: { + owner: "acme", + name: "widgets", + number: 42, + provider: "github", + platformHost: "github.com", + repoPath: "acme/widgets", + hideTabs: true, + hideWorkspaceAction: true, + autoSync: false, + }, + }, + context: new Map([ + [STORES_KEY, { + detail: detailStore, + pulls: { loadPulls: vi.fn() }, + activity: { loadActivity: vi.fn() }, + detailActivityView: createDetailActivityViewStore(), + settings, + workflowActions, + }], + [ACTIONS_KEY, { pull: [] }], + [UI_CONFIG_KEY, { hideStar: true }], + [NAVIGATE_KEY, vi.fn()], + ]), + }); + + await vi.waitFor(() => { + expect(visibleActionsTriggers()).toHaveLength(1); + expect(visibleButton("Approve")).not.toBeNull(); + expect(visibleButton("Squash and merge")).not.toBeNull(); + expect(visibleButton("Close")).not.toBeNull(); + }, WAIT); + + await visibleActionsTriggers()[0]!.click(); + await vi.waitFor(() => { + const workflowMenu = document.querySelector(".workflow-actions-menu"); + expect(workflowMenu).not.toBeNull(); + expect(workflowMenu!.textContent).toContain("GitHub Actions"); + expect(visibleButton("Release")).not.toBeNull(); + expect(visibleButton("Approve")).not.toBeNull(); + expect(visibleButton("Squash and merge")).not.toBeNull(); + expect(visibleButton("Close")).not.toBeNull(); + }, WAIT); + + await visibleActionsTriggers()[0]!.click(); + wrapper.style.width = "180px"; + await vi.waitFor(() => { + expect(document.querySelector(".pull-detail-content--actions-menu")).not.toBeNull(); + expect(visibleActionsTriggers()).toHaveLength(1); + expect(visibleButton("Approve")).toBeNull(); + expect(visibleButton("Squash and merge")).toBeNull(); + expect(visibleButton("Close")).toBeNull(); + }, WAIT); + + await visibleActionsTriggers()[0]!.click(); + await vi.waitFor(() => { + const menu = document.querySelector(".actions-menu-popover"); + expect(menu).not.toBeNull(); + const labels = Array.from(menu!.querySelectorAll("button"), (button) => button.textContent?.trim()); + expect(labels).toEqual(expect.arrayContaining(["Approve", "Squash and merge", "Close", "Release"])); + expect(menu!.textContent).toContain("GitHub Actions"); + expect(visibleActionsTriggers()).toHaveLength(1); + }, WAIT); + + wrapper.remove(); + }); +}); diff --git a/frontend/src/lib/components/detail/PullDetail.svelte b/frontend/src/lib/components/detail/PullDetail.svelte index bfb8444bb6..3fe5696dcb 100644 --- a/frontend/src/lib/components/detail/PullDetail.svelte +++ b/frontend/src/lib/components/detail/PullDetail.svelte @@ -1,12 +1,15 @@
-{#if presentation.kind === "conflict"} - -{:else} -
{ event.preventDefault(); submit(); }}> -

{workflow.name}

- - {#if errors.ref}{/if} + {#if presentation.kind === "conflict"} +
+ + {#if onreload}{/if} +
+ {:else if presentation.kind === "locating"} +
+

{workflow.name}

+

Locating run…

+
+ {:else if presentation.kind === "succeeded"} +
+

{workflow.name}

+

+ {presentation.message ?? "Workflow accepted."} +

+ {#if presentation.run} +
+
Run ID
{presentation.run.id}
+ {#if presentation.run.head_sha}
Head SHA
{presentation.run.head_sha}
{/if} +
+ {#if presentation.run.web_url && isSafeExternalHTTPURL(presentation.run.web_url)} + + Open on provider + + {/if} + {/if} + {#if onnewcycle}{/if} +
+ {:else if presentation.kind === "failed"} +
+

{workflow.name}

+ + {#if onnewcycle}{/if} +
+ {:else if presentation.kind === "uncertain"} +
+

{workflow.name}

+ + {#if presentation.candidates.length > 0} +
    + {#each presentation.candidates as candidate (candidate.id)} +
  • + {candidate.id} + {#if candidate.head_sha}{candidate.head_sha}{/if} + {#if candidate.web_url && isSafeExternalHTTPURL(candidate.web_url)} + + Open on provider + + {/if} +
  • + {/each} +
+ {/if} + {#if onnewcycle}{/if} +
+ {:else} + { event.preventDefault(); submit(); }}> +

{workflow.name}

+ + {#if errors.ref}{/if} - {#each workflow.inputs ?? [] as input, index (input.name)} -
- {#if input.type === "boolean"} - { drafts[draftKey] = { ...draft, values: { ...draft.values, [input.name]: checked } }; }} /> - {:else} - - {#if input.type === "choice" || input.type === "environment"} - + {input.name}{#if input.required} {/if} + + {#if input.required} + Required input. Checked and unchecked are both valid values. + {/if} {:else} - { const raw = event.currentTarget.value; drafts[draftKey] = { ...draft, values: { ...draft.values, [input.name]: input.type === "number" ? (raw === "" ? "" : Number(raw)) : raw } }; }} - aria-invalid={errors[input.name] ? "true" : undefined} - aria-describedby={errors[input.name] ? `${inputControlId(input.name, index)}-error` : undefined} - /> + + {#if input.type === "choice" || input.type === "environment"} + + {:else} + { const raw = event.currentTarget.value; drafts[draftKey] = { ...draft, values: { ...draft.values, [input.name]: input.type === "number" ? (raw === "" ? "" : Number(raw)) : raw } }; }} + aria-invalid={errors[input.name] ? "true" : undefined} + aria-describedby={errors[input.name] ? `${inputControlId(input.name, index)}-error` : undefined} + /> + {/if} {/if} - {/if} - {#if input.description}{input.description}{/if} - {#if errors[input.name]}{/if} -
- {/each} + {#if input.description}{input.description}{/if} + {#if errors[input.name]}{/if} +
+ {/each} - {#if unavailableReason}{/if} - {#if presentation.kind === "uncertain"}{/if} - - -{/if} + {#if unavailableReason}{/if} + + + {/if}
diff --git a/frontend/src/lib/components/actions/WorkflowDispatchForm.test.ts b/frontend/src/lib/components/actions/WorkflowDispatchForm.test.ts index 75cd5239e4..4cad6f0c5f 100644 --- a/frontend/src/lib/components/actions/WorkflowDispatchForm.test.ts +++ b/frontend/src/lib/components/actions/WorkflowDispatchForm.test.ts @@ -195,9 +195,9 @@ describe("WorkflowDispatchForm", () => { await fireEvent.click(screen.getByRole("button", { name: "Running workflow…" })); expect(onsubmit).not.toHaveBeenCalled(); - await view.rerender({ ...props, operation: available, state: { kind: "uncertain", message: "The provider may have accepted this run. Verify on the provider before trying again." } }); + await view.rerender({ ...props, operation: available, state: { kind: "uncertain", message: "The provider may have accepted this run. Verify on the provider before trying again.", candidates: [] } }); expect(screen.getByRole("alert").textContent).toContain("may have accepted"); - expect((screen.getByRole("button", { name: "Run workflow" }) as HTMLButtonElement).disabled).toBe(true); + expect(screen.queryByRole("button", { name: "Run workflow" })).toBeNull(); await view.rerender({ ...props, operation: available, state: { kind: "conflict" } }); expect(screen.getByRole("alert").textContent).toContain("Workflow definition changed. Reload workflows before running it."); @@ -213,4 +213,97 @@ describe("WorkflowDispatchForm", () => { await fireEvent.click(screen.getByRole("button", { name: "Run workflow" })); expect(onsubmit).toHaveBeenCalledWith({ ref: "release", inputs: {} }); }); + + it("exposes native required semantics without treating a required false boolean as missing", async () => { + const onsubmit = vi.fn(); + render(WorkflowDispatchForm, { + workflow: workflow([ + { name: "message", type: "string", required: true, has_default: false }, + { name: "retries", type: "number", required: true, has_default: false }, + { name: "approved", type: "boolean", required: true, has_default: false }, + { name: "channel", type: "choice", required: true, has_default: false, options: ["stable", "beta"] }, + { name: "target", type: "environment", required: true, has_default: false }, + ]), + environments, + initialRef: "trunk", + operation: available, + state: { kind: "idle" }, + onsubmit, + }); + + for (const control of [ + screen.getByRole("textbox", { name: "Git ref" }), + screen.getByRole("textbox", { name: "message" }), + screen.getByRole("spinbutton", { name: "retries" }), + screen.getByRole("combobox", { name: "channel" }), + screen.getByRole("combobox", { name: "target" }), + ]) { + expect((control as HTMLInputElement | HTMLSelectElement).required).toBe(true); + expect(control.getAttribute("aria-required")).toBe("true"); + } + + const approved = screen.getByRole("checkbox", { name: "approved" }); + expect((approved as HTMLInputElement).required).toBe(false); + const requiredDescription = document.getElementById(approved.getAttribute("aria-describedby") ?? ""); + expect(requiredDescription?.textContent).toContain("Required input"); + + await fireEvent.input(screen.getByRole("textbox", { name: "message" }), { target: { value: "release" } }); + await fireEvent.input(screen.getByRole("spinbutton", { name: "retries" }), { target: { value: "2" } }); + await fireEvent.change(screen.getByRole("combobox", { name: "channel" }), { target: { value: "stable" } }); + await fireEvent.change(screen.getByRole("combobox", { name: "target" }), { target: { value: "production" } }); + await fireEvent.click(screen.getByRole("button", { name: "Run workflow" })); + + expect(onsubmit).toHaveBeenCalledWith({ + ref: "trunk", + inputs: { + message: "release", + retries: 2, + approved: false, + channel: "stable", + target: "production", + }, + }); + }); + + it("announces locating and renders concrete accepted run details without a running submit label", async () => { + const props = { + workflow: workflow(), + environments, + initialRef: "trunk", + operation: available, + onsubmit: vi.fn(), + }; + const view = render(WorkflowDispatchForm, { + ...props, + state: { kind: "locating" } as const, + }); + expect(screen.getByRole("status").textContent).toContain("Locating run…"); + expect(screen.queryByRole("button", { name: "Running workflow…" })).toBeNull(); + + await view.rerender({ + ...props, + state: { + kind: "succeeded", + run: { + actor: "maintainer", + conclusion: "", + event: "workflow_dispatch", + head_sha: "0123456789abcdef", + id: "run-42", + name: "Deploy", + ref: "trunk", + run_number: 42, + status: "queued", + web_url: "https://github.com/acme/app/actions/runs/42", + workflow_id: "deploy.yml", + }, + } as const, + }); + expect(screen.getByText("run-42")).toBeTruthy(); + expect(screen.getByText("0123456789abcdef")).toBeTruthy(); + expect(screen.getByRole("link", { name: "Open accepted run on provider" }).getAttribute("href")).toBe( + "https://github.com/acme/app/actions/runs/42", + ); + expect(screen.queryByRole("button", { name: "Running workflow…" })).toBeNull(); + }); }); diff --git a/frontend/src/lib/components/actions/WorkflowRunList.svelte b/frontend/src/lib/components/actions/WorkflowRunList.svelte index dd405da051..91f146cab3 100644 --- a/frontend/src/lib/components/actions/WorkflowRunList.svelte +++ b/frontend/src/lib/components/actions/WorkflowRunList.svelte @@ -44,12 +44,18 @@
{#if safeRunURL} @@ -83,20 +89,61 @@
diff --git a/frontend/src/lib/components/actions/WorkflowRunList.test.ts b/frontend/src/lib/components/actions/WorkflowRunList.test.ts index d8c55945fc..55144cf0f2 100644 --- a/frontend/src/lib/components/actions/WorkflowRunList.test.ts +++ b/frontend/src/lib/components/actions/WorkflowRunList.test.ts @@ -7,7 +7,10 @@ type Run = components["schemas"]["WorkflowRunResponse"]; type Job = components["schemas"]["WorkflowRunJobResponse"]; const runs: Run[] = [{ actor: "octocat", conclusion: "success", created_at: "2026-08-27T12:30:00Z", event: "workflow_dispatch", head_sha: "0123456789abcdef", id: "run-2", name: "Deploy", ref: "main", run_number: 42, status: "completed", updated_at: "2026-08-27T12:32:00Z", web_url: "https://github.com/acme/app/actions/runs/2", workflow_id: "deploy.yml" }]; -const jobs: Record = { "run-2": [{ id: "job-2", name: "Publish", status: "completed", conclusion: "success", steps: [{ number: 2, name: "Upload", status: "completed", conclusion: "success" }, { number: 1, name: "Build", status: "completed", conclusion: "success" }] }] }; +const jobs: Record = { "run-2": [ + { id: "job-9", name: "Verify", status: "completed", conclusion: "success", steps: [] }, + { id: "job-2", name: "Publish", status: "completed", conclusion: "success", steps: [{ number: 2, name: "Upload", status: "completed", conclusion: "success" }, { number: 1, name: "Build", status: "completed", conclusion: "success" }] }, +] }; it("exposes compact textual run data, local time, and secure provider links", () => { render(WorkflowRunList, { runs, jobs: {}, loadingJobs: [], onexpand: vi.fn(), oncollapse: vi.fn() }); @@ -42,6 +45,10 @@ it("owns one lazy job request per exact expand and collapse transition and prese expect(onexpand).toHaveBeenCalledTimes(1); expect(onexpand).toHaveBeenCalledWith("run-2"); expect(disclosure.getAttribute("aria-expanded")).toBe("true"); + expect(screen.getAllByRole("button", { name: /Verify|Publish/ }).map((item) => item.textContent)).toEqual([ + expect.stringContaining("Verify"), + expect.stringContaining("Publish"), + ]); const job = screen.getByRole("button", { name: /Publish/ }); expect(job.textContent).toContain("completed · success"); await fireEvent.click(job); diff --git a/frontend/src/lib/components/detail/PullDetail.svelte b/frontend/src/lib/components/detail/PullDetail.svelte index 3fe5696dcb..08d4861b70 100644 --- a/frontend/src/lib/components/detail/PullDetail.svelte +++ b/frontend/src/lib/components/detail/PullDetail.svelte @@ -1359,20 +1359,30 @@ .reverse() .find((candidate) => candidate.request.workflowId === workflow.id); if (!dispatch) return { kind: "idle" }; - if (dispatch.kind === "pending" || dispatch.kind === "locating") { - return { kind: "pending" }; - } - if (dispatch.kind === "succeeded") return { kind: "succeeded" }; + if (dispatch.kind === "pending") return { kind: "pending" }; + if (dispatch.kind === "locating") return { kind: "locating" }; + if (dispatch.kind === "succeeded") return { kind: "succeeded", run: dispatch.run }; if ( dispatch.kind === "failed" && dispatch.error._tag === "ApiProblemError" && dispatch.error.problem.code === ProblemCodes.conflict + && dispatch.error.problem.details?.["reason"] === "workflow_definition_changed" ) { return { kind: "conflict" }; } + if (dispatch.kind === "failed") { + return { kind: "failed", message: workflowDispatchFailureMessage(dispatch) }; + } + if (dispatch.kind === "locating_timed_out") { + return { + kind: "succeeded", + message: "The provider accepted the workflow, but its run was not observed.", + }; + } return { kind: "uncertain", message: workflowDispatchFailureMessage(dispatch), + candidates: dispatch.candidates, }; }, ); @@ -1410,8 +1420,15 @@ } function reloadWorkflowCatalog(): void { - if (!workflowCatalogDemandEnabled) return; - workflowActions.claimRepository(workflowRepositoryOwner, routeRef); + const workflow = workflowDialogWorkflow; + if (!workflowCatalogDemandEnabled || !workflow) return; + workflowActions.refreshCatalog(routeRef, workflow.id); + } + + function newWorkflowDispatchCycle(): void { + const workflow = workflowDialogWorkflow; + if (!workflowCatalogDemandEnabled || !workflow) return; + workflowActions.newDispatchCycle(routeRef, workflow.id); } function claimWorkflowRepository(ref: ProviderRouteRef | null): Attachment { @@ -2997,6 +3014,7 @@ onsubmit={submitWorkflow} onclose={() => { workflowDialogWorkflow = null; }} onreload={reloadWorkflowCatalog} + onnewcycle={newWorkflowDispatchCycle} /> {/if} diff --git a/frontend/src/lib/components/detail/PullDetail.test.ts b/frontend/src/lib/components/detail/PullDetail.test.ts index a83e2dff7a..af6c20397b 100644 --- a/frontend/src/lib/components/detail/PullDetail.test.ts +++ b/frontend/src/lib/components/detail/PullDetail.test.ts @@ -792,6 +792,37 @@ describe("PullDetail provider workflow actions", () => { expect(screen.queryByRole("button", { name: "Close" })).toBeNull(); }); + it("routes definition-conflict recovery through a real catalog refresh without replaying POST", async () => { + const rendered = await openReleaseWorkflow(pullDetail()); + const refreshCatalog = vi.spyOn(rendered.workflowActions, "refreshCatalog"); + rendered.api.POST.mockResolvedValue({ + error: { + code: "conflict", + detail: "Workflow definition changed.", + details: { reason: "workflow_definition_changed" }, + status: 409, + title: "Conflict", + type: "about:blank", + }, + response: new Response(null, { status: 409 }), + }); + + await fireEvent.click(screen.getByRole("button", { name: "Run workflow" })); + const reload = await screen.findByRole("button", { name: "Reload workflows" }); + await fireEvent.click(reload); + + expect(refreshCatalog).toHaveBeenCalledWith( + expect.objectContaining({ + provider: "github", + platformHost: "github.com", + owner: "acme", + name: "widget", + }), + "release.yml", + ); + expect(rendered.api.POST).toHaveBeenCalledTimes(1); + }); + it("tears down demand and confirmation without dispatch when Actions mode is disabled", async () => { const detail = pullDetail(); const rendered = await openReleaseWorkflow(detail); diff --git a/frontend/src/lib/stores/workflow-actions-workflow.test.ts b/frontend/src/lib/stores/workflow-actions-workflow.test.ts index 7f8d41f1b7..fab3ca7fcd 100644 --- a/frontend/src/lib/stores/workflow-actions-workflow.test.ts +++ b/frontend/src/lib/stores/workflow-actions-workflow.test.ts @@ -814,3 +814,242 @@ it.effect("starts one replacement jobs read for a consumer that joined the faili }), ); }); + +it.effect("refreshes a cached catalog after a definition conflict and starts a fresh cycle without replaying POST", () => { + const refreshed = catalog(); + refreshed.workflows = [{ + ...refreshed.workflows[0]!, + definition_sha: "definition-b", + inputs: [{ + name: "channel", + type: "choice", + required: true, + has_default: true, + default: "stable", + options: ["stable", "beta"], + }], + }]; + const conflict = { + code: "conflict", + detail: "Workflow definition changed.", + details: { reason: "workflow_definition_changed" }, + status: 409, + title: "Conflict", + type: "about:blank", + }; + const probe = makeApiProbe({ + catalog: (call) => call === 1 ? catalog() : refreshed, + dispatch: () => ({ error: conflict, response: new Response(null, { status: 409 }) }), + }); + return withWorkflow( + probe, + Effect.gen(function* () { + const workflow = yield* WorkflowActionsWorkflow; + yield* workflow.selectWorkflow(github, "deploy.yml"); + const owner = yield* workflow.watchRepository("surface", github, () => {}).pipe(Effect.forkChild); + yield* settle; + yield* workflow.dispatch(dispatchInput()); + yield* settle; + assert.strictEqual((yield* workflow.snapshot(github)).dispatches.at(-1)?.kind, "failed"); + + yield* workflow.refreshCatalog(github, "deploy.yml"); + const snapshot = yield* workflow.snapshot(github); + assert.strictEqual(probe.calls.catalog, 2); + assert.strictEqual(probe.calls.dispatch, 1); + assert.strictEqual(snapshot.selectedWorkflow?.definition_sha, "definition-b"); + assert.deepStrictEqual(snapshot.selectedWorkflow?.inputs?.map((input) => input.name), ["channel"]); + assert.isFalse(snapshot.dispatches.some((state) => state.request.workflowId === "deploy.yml")); + yield* Fiber.interrupt(owner); + }), + ); +}); + +it.effect("requires an explicit new cycle before deliberately dispatching the same workflow twice", () => { + const probe = makeApiProbe({ + dispatch: () => ({ + accepted: true, + locating_run: false, + actor: "octocat", + run: run({ status: "completed", conclusion: "success" }), + }), + }); + return withWorkflow( + probe, + Effect.gen(function* () { + const workflow = yield* WorkflowActionsWorkflow; + yield* workflow.dispatch(dispatchInput()); + yield* settle; + assert.strictEqual(probe.calls.dispatch, 1); + assert.strictEqual((yield* workflow.snapshot(github)).dispatches.at(-1)?.kind, "succeeded"); + + yield* workflow.newDispatchCycle(github, "deploy.yml"); + assert.strictEqual(probe.calls.dispatch, 1); + assert.isFalse((yield* workflow.snapshot(github)).dispatches.some( + (state) => state.request.workflowId === "deploy.yml", + )); + + yield* workflow.dispatch(dispatchInput()); + yield* settle; + assert.strictEqual(probe.calls.dispatch, 2); + assert.strictEqual((yield* workflow.snapshot(github)).dispatches.at(-1)?.kind, "succeeded"); + }), + ); +}); + +it.effect("starts each queued dispatch reconciliation window immediately before its own POST", () => { + const firstResponse = Promise.withResolvers(); + const probe = makeApiProbe({ + dispatch: (call) => call === 1 + ? firstResponse.promise + : { accepted: true, locating_run: true, actor: "octocat" }, + runs: () => ({ repo: apiRepo(), items: [], exhausted: true }), + }); + return withWorkflow( + probe, + Effect.gen(function* () { + const workflow = yield* WorkflowActionsWorkflow; + const first = yield* workflow.dispatch(dispatchInput()); + const second = yield* workflow.dispatch(dispatchInput()); + yield* settle; + assert.strictEqual(probe.calls.dispatch, 1); + + yield* TestClock.adjust("61 seconds"); + firstResponse.resolve({ + accepted: true, + locating_run: false, + actor: "octocat", + run: run({ status: "completed", conclusion: "success" }), + }); + yield* settle; + assert.strictEqual(probe.calls.dispatch, 2); + assert.strictEqual( + (yield* workflow.snapshot(github)).dispatches.find((state) => state.request.id === second.id)?.kind, + "locating", + ); + assert.strictEqual( + (yield* workflow.snapshot(github)).dispatches.find((state) => state.request.id === first.id)?.kind, + "succeeded", + ); + + yield* TestClock.adjust("59 seconds"); + yield* settle; + assert.strictEqual( + (yield* workflow.snapshot(github)).dispatches.find((state) => state.request.id === second.id)?.kind, + "locating", + ); + yield* TestClock.adjust("1 second"); + yield* settle; + assert.strictEqual( + (yield* workflow.snapshot(github)).dispatches.find((state) => state.request.id === second.id)?.kind, + "locating_timed_out", + ); + assert.strictEqual(probe.calls.dispatch, 2); + }), + ); +}); + +it.effect("never reconciles another maintainer's run when the dispatch actor is unknown", () => { + const problem = { + code: "mutationOutcomeUnknown", + detail: "The provider may have accepted the dispatch.", + status: 502, + title: "Outcome unknown", + type: "about:blank", + }; + const probe = makeApiProbe({ + dispatch: () => ({ error: problem, response: new Response(null, { status: 502 }) }), + runs: () => ({ repo: apiRepo(), exhausted: true, items: [run({ actor: "another-maintainer" })] }), + }); + return withWorkflow( + probe, + Effect.gen(function* () { + const workflow = yield* WorkflowActionsWorkflow; + yield* workflow.dispatch({ ...dispatchInput(), actor: undefined }); + yield* settle; + const state = (yield* workflow.snapshot(github)).dispatches.at(-1); + assert.strictEqual(state?.kind, "uncertain"); + assert.deepStrictEqual(state?.kind === "uncertain" ? state.candidates : [], []); + assert.strictEqual(probe.calls.dispatch, 1); + }), + ); +}); + +it.effect("uses an accepted response actor to reconcile a locating run", () => { + const probe = makeApiProbe({ + dispatch: () => ({ accepted: true, locating_run: true, actor: "octocat" }), + runs: () => ({ repo: apiRepo(), exhausted: true, items: [run({ actor: "octocat" })] }), + }); + return withWorkflow( + probe, + Effect.gen(function* () { + const workflow = yield* WorkflowActionsWorkflow; + yield* workflow.dispatch({ ...dispatchInput(), actor: undefined }); + yield* settle; + const state = (yield* workflow.snapshot(github)).dispatches.at(-1); + assert.strictEqual(state?.kind, "succeeded"); + assert.strictEqual(state?.request.actor, "octocat"); + assert.strictEqual(probe.calls.dispatch, 1); + }), + ); +}); + +it.effect("appends older run pages and preserves them while polling page one", () => { + const observedCursors: unknown[] = []; + let firstPageReads = 0; + const probe = makeApiProbe({ + runs: (_call, options) => { + const cursor = options.params?.query?.["cursor"]; + observedCursors.push(cursor); + if (cursor === "cursor-2") { + return { + repo: apiRepo(), + items: [run({ id: "run-older-2", run_number: 10 }), run({ id: "run-older-1", run_number: 11 })], + exhausted: true, + }; + } + firstPageReads += 1; + return { + repo: apiRepo(), + items: [run({ + id: firstPageReads === 1 ? "run-current-1" : "run-current-2", + run_number: firstPageReads === 1 ? 12 : 13, + status: "completed", + conclusion: "success", + })], + next_cursor: "cursor-2", + exhausted: false, + }; + }, + }); + return withWorkflow( + probe, + Effect.gen(function* () { + const workflow = yield* WorkflowActionsWorkflow; + yield* workflow.selectWorkflow(github, "deploy.yml"); + const owner = yield* workflow.watchRepository("surface", github, () => {}).pipe(Effect.forkChild); + yield* settle; + let snapshot = yield* workflow.snapshot(github); + assert.deepStrictEqual(snapshot.runs.map((item) => item.id), ["run-current-1"]); + assert.strictEqual(snapshot.runsPage.nextCursor, "cursor-2"); + assert.isFalse(snapshot.runsPage.exhausted); + + yield* workflow.loadMoreRuns(github); + snapshot = yield* workflow.snapshot(github); + assert.deepStrictEqual( + snapshot.runs.map((item) => item.id), + ["run-current-1", "run-older-2", "run-older-1"], + ); + assert.isTrue(snapshot.runsPage.exhausted); + + yield* TestClock.adjust("30 seconds"); + yield* settle; + snapshot = yield* workflow.snapshot(github); + assert.deepStrictEqual( + snapshot.runs.map((item) => item.id), + ["run-current-2", "run-older-2", "run-older-1"], + ); + assert.deepStrictEqual(observedCursors, [undefined, "cursor-2", undefined]); + yield* Fiber.interrupt(owner); + }), + ); +}); diff --git a/frontend/src/lib/stores/workflow-actions-workflow.ts b/frontend/src/lib/stores/workflow-actions-workflow.ts index dd00ad4827..7bdece7376 100644 --- a/frontend/src/lib/stores/workflow-actions-workflow.ts +++ b/frontend/src/lib/stores/workflow-actions-workflow.ts @@ -47,7 +47,7 @@ export interface AcceptedWorkflowDispatch { readonly dispatchRef: string; readonly inputs: Readonly>; readonly actor?: string | undefined; - readonly acceptedAt: number; + readonly startedAt?: number | undefined; } export type WorkflowDispatchState = @@ -68,12 +68,18 @@ export interface WorkflowActionsLoading { readonly runs: boolean; readonly jobs: readonly string[]; } +export interface WorkflowRunsPageState { + readonly nextCursor: string | null; + readonly exhausted: boolean; + readonly loadingMore: boolean; +} export interface WorkflowActionsSnapshot { readonly ref: ProviderRouteRef; readonly catalog: WorkflowCatalog | null; readonly selectedWorkflow: WorkflowDefinition | null; readonly runs: readonly WorkflowRun[]; + readonly runsPage: WorkflowRunsPageState; readonly jobs: Readonly>; readonly loading: WorkflowActionsLoading; readonly dispatches: readonly WorkflowDispatchState[]; @@ -90,6 +96,9 @@ interface WorkflowActionsWorkflowShape { ) => Effect.Effect; readonly watchJobs: (owner: string, ref: ProviderRouteRef, runId: string) => Effect.Effect; readonly selectWorkflow: (ref: ProviderRouteRef, workflowId: string | null) => Effect.Effect; + readonly refreshCatalog: (ref: ProviderRouteRef, workflowId: string) => Effect.Effect; + readonly loadMoreRuns: (ref: ProviderRouteRef) => Effect.Effect; + readonly newDispatchCycle: (ref: ProviderRouteRef, workflowId: string) => Effect.Effect; readonly dispatch: (input: WorkflowDispatchInput) => Effect.Effect; readonly snapshot: (ref: ProviderRouteRef) => Effect.Effect; readonly setEnabled: (enabled: boolean) => Effect.Effect; @@ -113,6 +122,7 @@ interface RepositoryEntry { selectedWorkflowId: string | null; loopGeneration: number; loopRunning: boolean; + readonly runPages: Map; } interface JobDemand { @@ -124,6 +134,13 @@ interface JobDemand { running: boolean; restartAfterFailure: boolean; } +interface WorkflowRunPagination { + firstPage: readonly WorkflowRun[]; + olderRuns: readonly WorkflowRun[]; + nextCursor: string | null; + exhausted: boolean; + loadingMore: boolean; +} interface DispatchCommand { readonly request: AcceptedWorkflowDispatch; @@ -177,6 +194,7 @@ function emptySnapshot(ref: ProviderRouteRef): WorkflowActionsSnapshot { catalog: null, selectedWorkflow: null, runs: [], + runsPage: { nextCursor: null, exhausted: false, loadingMore: false }, jobs: {}, loading: { catalog: false, runs: false, jobs: [] }, dispatches: [], @@ -189,7 +207,6 @@ function isTerminalRun(run: WorkflowRun): boolean { return status === "completed" || status === "cancelled" || status === "failure" || status === "success"; } - function dispatchNeedsPolling(state: WorkflowDispatchState, now: number): boolean { switch (state.kind) { case "pending": @@ -199,7 +216,8 @@ function dispatchNeedsPolling(state: WorkflowDispatchState, now: number): boolea case "locating": return true; case "uncertain": - return now < state.request.acceptedAt + reconciliationWindowMs; + return state.request.startedAt !== undefined + && now < state.request.startedAt + reconciliationWindowMs; case "failed": case "locating_timed_out": return false; @@ -207,15 +225,17 @@ function dispatchNeedsPolling(state: WorkflowDispatchState, now: number): boolea } function matchingCandidates(request: AcceptedWorkflowDispatch, runs: readonly WorkflowRun[]): readonly WorkflowRun[] { - const earliest = request.acceptedAt - candidateClockSkewMs; - const latest = request.acceptedAt + reconciliationWindowMs; + const actor = request.actor?.trim(); + if (request.startedAt === undefined || !actor) return []; + const earliest = request.startedAt - candidateClockSkewMs; + const latest = request.startedAt + reconciliationWindowMs; return runs.filter((candidate) => { const createdAt = candidate.created_at === undefined ? Number.NaN : Date.parse(candidate.created_at); return ( candidate.workflow_id === request.workflowId && candidate.event === "workflow_dispatch" && candidate.ref === request.dispatchRef && - (request.actor === undefined || candidate.actor === request.actor) && + candidate.actor === actor && Number.isFinite(createdAt) && createdAt >= earliest && createdAt <= latest @@ -223,6 +243,19 @@ function matchingCandidates(request: AcceptedWorkflowDispatch, runs: readonly Wo }); } +function runsPageState(page: WorkflowRunPagination): WorkflowRunsPageState { + return { + nextCursor: page.nextCursor, + exhausted: page.exhausted, + loadingMore: page.loadingMore, + }; +} + +function combinedRuns(page: WorkflowRunPagination): readonly WorkflowRun[] { + const firstPageIDs = new Set(page.firstPage.map((run) => run.id)); + return [...page.firstPage, ...page.olderRuns.filter((run) => !firstPageIDs.has(run.id))]; +} + function reconcileDispatchStates( states: readonly WorkflowDispatchState[], runs: readonly WorkflowRun[], @@ -242,7 +275,10 @@ function reconcileDispatchStates( case "locating": { const candidates = matchingCandidates(state.request, runs); if (candidates.length === 1) return { kind: "succeeded", request: state.request, run: candidates[0] }; - if (now >= state.request.acceptedAt + reconciliationWindowMs) { + if ( + state.request.startedAt !== undefined + && now >= state.request.startedAt + reconciliationWindowMs + ) { return { kind: "locating_timed_out", request: state.request }; } return state; @@ -260,6 +296,14 @@ function isDispatchOutcomeUncertain(error: WorkflowActionsError): boolean { ); } +function actorFromMutationError(error: WorkflowActionsError): string | undefined { + if (error._tag !== "ApiProblemError" || error.problem.code !== "mutationOutcomeUnknown") { + return undefined; + } + const actor = error.problem.details?.["actor"]; + return typeof actor === "string" && actor.trim() !== "" ? actor.trim() : undefined; +} + export const WorkflowActionsWorkflowLive = Layer.effect(WorkflowActionsWorkflow)( Effect.gen(function* () { const api = yield* GeneratedApi; @@ -287,11 +331,26 @@ export const WorkflowActionsWorkflowLive = Layer.effect(WorkflowActionsWorkflow) selectedWorkflowId: null, loopGeneration: 0, loopRunning: false, + runPages: new Map(), }; repositories.set(key, created); return created; } + function runPageFor(entry: RepositoryEntry, workflowId: string): WorkflowRunPagination { + const existing = entry.runPages.get(workflowId); + if (existing !== undefined) return existing; + const created: WorkflowRunPagination = { + firstPage: [], + olderRuns: [], + nextCursor: null, + exhausted: false, + loadingMore: false, + }; + entry.runPages.set(workflowId, created); + return created; + } + function notify(observers: readonly WorkflowActionsObserver[], snapshot: WorkflowActionsSnapshot): Effect.Effect { return Effect.sync(() => { for (const observer of observers) { @@ -331,12 +390,16 @@ export const WorkflowActionsWorkflowLive = Layer.effect(WorkflowActionsWorkflow) ); } - function readRuns(entry: RepositoryEntry, workflowId: string) { + function readRuns(entry: RepositoryEntry, workflowId: string, cursor?: string) { return api.execute("GET workflow runs", (signal) => api.client.GET(providerActionsPath(entry.ref, "/runs"), { params: { path: providerRouteParams(entry.ref), - query: { workflow_id: workflowId, per_page: 50 }, + query: { + workflow_id: workflowId, + per_page: 50, + ...(cursor !== undefined && { cursor }), + }, }, signal, }), @@ -396,20 +459,23 @@ export const WorkflowActionsWorkflowLive = Layer.effect(WorkflowActionsWorkflow) ); } - const loadCatalog = Effect.fn("WorkflowActions.loadCatalog")(function* (entry: RepositoryEntry) { - if (entry.snapshot.catalog !== null) return; + const loadCatalog = Effect.fn("WorkflowActions.loadCatalog")(function* ( + entry: RepositoryEntry, + force = false, + ) { + if (!force && entry.snapshot.catalog !== null) return true; yield* updateSnapshot(entry.key, (snapshot) => ({ ...snapshot, loading: { ...snapshot.loading, catalog: true }, })); - yield* readCatalog(entry).pipe( + return yield* readCatalog(entry).pipe( Effect.matchEffect({ onFailure: (error) => updateSnapshot(entry.key, (snapshot) => ({ ...snapshot, error, loading: { ...snapshot.loading, catalog: false }, - })), + })).pipe(Effect.as(false)), onSuccess: (catalog) => updateSnapshot(entry.key, (snapshot, current) => ({ ...snapshot, @@ -418,7 +484,7 @@ export const WorkflowActionsWorkflowLive = Layer.effect(WorkflowActionsWorkflow) catalog.workflows?.find((workflow) => workflow.id === current.selectedWorkflowId) ?? null, error: null, loading: { ...snapshot.loading, catalog: false }, - })), + })).pipe(Effect.as(true)), }), ); }); @@ -440,42 +506,53 @@ export const WorkflowActionsWorkflowLive = Layer.effect(WorkflowActionsWorkflow) onFailure: (error) => Clock.currentTimeMillis.pipe( Effect.flatMap((now) => - updateSnapshot(entry.key, (snapshot, current) => ({ - ...snapshot, - dispatches: reconcileWorkflowDispatchStates( - snapshot.dispatches, - workflowId, - snapshot.runs, - now, - ), - ...(current.selectedWorkflowId === workflowId - ? { - error, - loading: { ...snapshot.loading, runs: false }, - } - : {}), - })), + updateSnapshot(entry.key, (snapshot, current) => { + const firstPage = runPageFor(current, workflowId).firstPage; + return { + ...snapshot, + dispatches: reconcileWorkflowDispatchStates( + snapshot.dispatches, + workflowId, + firstPage, + now, + ), + ...(current.selectedWorkflowId === workflowId + ? { + error, + loading: { ...snapshot.loading, runs: false }, + } + : {}), + }; + }), ), ), onSuccess: (response) => Clock.currentTimeMillis.pipe( Effect.flatMap((now) => - updateSnapshot(entry.key, (snapshot, current) => ({ - ...snapshot, - ...(current.selectedWorkflowId === workflowId - ? { - runs: response.items ?? [], - error: null, - loading: { ...snapshot.loading, runs: false }, - } - : {}), - dispatches: reconcileWorkflowDispatchStates( - snapshot.dispatches, - workflowId, - response.items ?? [], - now, - ), - })), + updateSnapshot(entry.key, (snapshot, current) => { + const items = response.items ?? []; + const page = runPageFor(current, workflowId); + page.firstPage = items; + page.nextCursor = response.next_cursor ?? null; + page.exhausted = response.exhausted; + return { + ...snapshot, + ...(current.selectedWorkflowId === workflowId + ? { + runs: combinedRuns(page), + runsPage: runsPageState(page), + error: null, + loading: { ...snapshot.loading, runs: false }, + } + : {}), + dispatches: reconcileWorkflowDispatchStates( + snapshot.dispatches, + workflowId, + items, + now, + ), + }; + }), ), ), }), @@ -591,39 +668,136 @@ export const WorkflowActionsWorkflowLive = Layer.effect(WorkflowActionsWorkflow) ), ); + const loadMoreRuns = Effect.fn("WorkflowActions.loadMoreRuns")(function* (ref: ProviderRouteRef) { + const entry = entryFor(ref); + const request = yield* registry.withPermit( + Effect.sync(() => { + if (!enabled || entry.selectedWorkflowId === null) return undefined; + const page = runPageFor(entry, entry.selectedWorkflowId); + if (page.loadingMore || page.exhausted || page.nextCursor === null) return undefined; + page.loadingMore = true; + return { workflowId: entry.selectedWorkflowId, cursor: page.nextCursor }; + }), + ); + if (request === undefined) return; + yield* updateSnapshot(entry.key, (snapshot, current) => ({ + ...snapshot, + runsPage: runsPageState(runPageFor(current, request.workflowId)), + })); + yield* readRuns(entry, request.workflowId, request.cursor).pipe( + Effect.matchEffect({ + onFailure: (error) => + updateSnapshot(entry.key, (snapshot, current) => { + const page = runPageFor(current, request.workflowId); + page.loadingMore = false; + return { + ...snapshot, + error, + ...(current.selectedWorkflowId === request.workflowId + ? { runsPage: runsPageState(page) } + : {}), + }; + }), + onSuccess: (response) => + updateSnapshot(entry.key, (snapshot, current) => { + const page = runPageFor(current, request.workflowId); + const knownIDs = new Set([...page.firstPage, ...page.olderRuns].map((run) => run.id)); + page.olderRuns = [ + ...page.olderRuns, + ...(response.items ?? []).filter((run) => !knownIDs.has(run.id)), + ]; + page.nextCursor = response.next_cursor ?? null; + page.exhausted = response.exhausted; + page.loadingMore = false; + return { + ...snapshot, + error: null, + ...(current.selectedWorkflowId === request.workflowId + ? { + runs: combinedRuns(page), + runsPage: runsPageState(page), + } + : {}), + }; + }), + }), + ); + }); + + const newDispatchCycle = Effect.fn("WorkflowActions.newDispatchCycle")(function* ( + ref: ProviderRouteRef, + workflowId: string, + ) { + const key = workflowRepositoryKey(ref); + yield* updateSnapshot(key, (snapshot) => ({ + ...snapshot, + dispatches: snapshot.dispatches.filter((state) => + state.request.workflowId !== workflowId + || state.kind === "pending" + || state.kind === "locating" + ), + })); + yield* stopRepositoryLoopIfIdle(key); + }); + + const refreshCatalog = Effect.fn("WorkflowActions.refreshCatalog")(function* ( + ref: ProviderRouteRef, + workflowId: string, + ) { + const entry = entryFor(ref); + if (!(yield* loadCatalog(entry, true))) return; + yield* newDispatchCycle(entry.ref, workflowId); + yield* restartRepositoryLoop(entry.key); + }); + + const dispatchQueue = yield* makeOrderedCommandQueue( "workflow actions dispatch", (command) => Deferred.await(command.admitted).pipe( Effect.andThen( - api.execute("POST workflow dispatch", (signal) => - api.client.POST(providerActionsPath(command.request.ref, "/workflows/{workflow_id}/dispatch"), { - params: { - path: { - ...providerRouteParams(command.request.ref), - workflow_id: command.request.workflowId, + Effect.gen(function* () { + const startedAt = yield* Clock.currentTimeMillis; + yield* updateSnapshot(workflowRepositoryKey(command.request.ref), (snapshot) => ({ + ...snapshot, + dispatches: snapshot.dispatches.map((state): WorkflowDispatchState => + state.request.id === command.request.id + ? { ...state, request: { ...state.request, startedAt } } + : state + ), + })); + return yield* api.execute("POST workflow dispatch", (signal) => + api.client.POST(providerActionsPath(command.request.ref, "/workflows/{workflow_id}/dispatch"), { + params: { + path: { + ...providerRouteParams(command.request.ref), + workflow_id: command.request.workflowId, + }, }, - }, - body: { - expected_definition_sha: command.request.expectedDefinitionSha, - inputs: command.request.inputs, - ref: command.request.dispatchRef, - }, - signal, - }), - ), + body: { + expected_definition_sha: command.request.expectedDefinitionSha, + inputs: command.request.inputs, + ref: command.request.dispatchRef, + }, + signal, + }), + ); + }), ), Effect.matchEffect({ onFailure: (error) => updateSnapshot(workflowRepositoryKey(command.request.ref), (snapshot) => ({ ...snapshot, - dispatches: snapshot.dispatches.map((state): WorkflowDispatchState => - state.request.id !== command.request.id - ? state - : isDispatchOutcomeUncertain(error) - ? { kind: "uncertain", request: command.request, error, candidates: [] } - : { kind: "failed", request: command.request, error }, - ), + dispatches: snapshot.dispatches.map((state): WorkflowDispatchState => { + if (state.request.id !== command.request.id) return state; + const actor = actorFromMutationError(error); + const request = actor === undefined + ? state.request + : { ...state.request, actor }; + return isDispatchOutcomeUncertain(error) + ? { kind: "uncertain", request, error, candidates: [] } + : { kind: "failed", request, error }; + }), })).pipe( Effect.andThen( isDispatchOutcomeUncertain(error) @@ -636,10 +810,12 @@ export const WorkflowActionsWorkflowLive = Layer.effect(WorkflowActionsWorkflow) ...snapshot, dispatches: snapshot.dispatches.map((state): WorkflowDispatchState => { if (state.request.id !== command.request.id) return state; + const actor = response.actor?.trim() || response.run?.actor.trim(); + const request = actor ? { ...state.request, actor } : state.request; if (response.run !== undefined) { - return { kind: "succeeded", request: command.request, run: response.run }; + return { kind: "succeeded", request, run: response.run }; } - return { kind: "locating", request: command.request }; + return { kind: "locating", request }; }), })).pipe( Effect.andThen( @@ -891,10 +1067,14 @@ export const WorkflowActionsWorkflowLive = Layer.effect(WorkflowActionsWorkflow) const entry = entryFor(ref); yield* updateSnapshot(entry.key, (snapshot, current) => { current.selectedWorkflowId = workflowId; + const page = workflowId === null ? undefined : runPageFor(current, workflowId); return { ...snapshot, selectedWorkflow: snapshot.catalog?.workflows?.find((workflow) => workflow.id === workflowId) ?? null, - runs: [], + runs: page === undefined ? [] : combinedRuns(page), + runsPage: page === undefined + ? { nextCursor: null, exhausted: false, loadingMore: false } + : runsPageState(page), error: null, loading: { ...snapshot.loading, runs: false }, }; @@ -903,7 +1083,6 @@ export const WorkflowActionsWorkflowLive = Layer.effect(WorkflowActionsWorkflow) }); const dispatch = Effect.fn("WorkflowActions.dispatch")(function* (input: WorkflowDispatchInput) { - const acceptedAt = yield* Clock.currentTimeMillis; const sequence = yield* Ref.updateAndGet(requestSequence, (current) => current + 1); const request: AcceptedWorkflowDispatch = { id: `workflow-dispatch-${sequence}`, @@ -913,7 +1092,6 @@ export const WorkflowActionsWorkflowLive = Layer.effect(WorkflowActionsWorkflow) dispatchRef: input.dispatchRef, inputs: { ...input.inputs }, ...(input.actor !== undefined && { actor: input.actor }), - acceptedAt, }; const entry = entryFor(request.ref); const admitted = yield* Deferred.make(); @@ -945,8 +1123,10 @@ export const WorkflowActionsWorkflowLive = Layer.effect(WorkflowActionsWorkflow) entry.owners.clear(); entry.loopRunning = false; entry.loopGeneration += 1; + for (const page of entry.runPages.values()) page.loadingMore = false; entry.snapshot = { ...entry.snapshot, + runsPage: { ...entry.snapshot.runsPage, loadingMore: false }, loading: { catalog: false, runs: false, jobs: [] }, }; } @@ -969,6 +1149,9 @@ export const WorkflowActionsWorkflowLive = Layer.effect(WorkflowActionsWorkflow) watchRepository, watchJobs, selectWorkflow, + refreshCatalog, + loadMoreRuns, + newDispatchCycle, dispatch, snapshot, setEnabled, diff --git a/frontend/src/lib/stores/workflow-actions.svelte.test.ts b/frontend/src/lib/stores/workflow-actions.svelte.test.ts index 3c296d6458..aed6553fdf 100644 --- a/frontend/src/lib/stores/workflow-actions.svelte.test.ts +++ b/frontend/src/lib/stores/workflow-actions.svelte.test.ts @@ -156,11 +156,10 @@ describe("workflow Actions projection store", () => { store.claimRepository("actions-page", ref); await vi.waitFor(() => expect(store.getCatalog(ref)?.workflows).toHaveLength(1)); expect(store.getEnvironments(ref)).toEqual([{ name: "production" }]); - expect(store.getRuns(ref).map((item) => item.id)).toEqual(["run-1"]); - expect(store.getLoading(ref)).toEqual({ catalog: false, runs: false, jobs: [] }); - store.selectWorkflow(ref, "deploy.yml"); await vi.waitFor(() => expect(store.getSelectedWorkflow(ref)?.name).toBe("Deploy")); + await vi.waitFor(() => expect(store.getRuns(ref).map((item) => item.id)).toEqual(["run-1"])); + expect(store.getLoading(ref)).toEqual({ catalog: false, runs: false, jobs: [] }); expect(probe.get.mock.calls.some(([path]) => String(path).endsWith("/jobs"))).toBe(false); store.expandRun("actions-page:run-1", ref, "run-1"); @@ -176,6 +175,15 @@ describe("workflow Actions projection store", () => { }); await vi.waitFor(() => expect(store.getDispatches(ref).at(-1)?.kind).toBe("succeeded")); expect(probe.post).toHaveBeenCalledTimes(1); + store.newDispatchCycle(ref, "deploy.yml"); + await vi.waitFor(() => expect(store.getDispatches(ref)).toEqual([])); + expect(probe.post).toHaveBeenCalledTimes(1); + + store.refreshCatalog(ref, "deploy.yml"); + await vi.waitFor(() => expect( + probe.get.mock.calls.filter(([path]) => String(path).endsWith("/workflows")), + ).toHaveLength(2)); + expect(probe.post).toHaveBeenCalledTimes(1); }); it("releases synchronous owner launchers and disables all future reads", async () => { diff --git a/frontend/src/lib/stores/workflow-actions.svelte.ts b/frontend/src/lib/stores/workflow-actions.svelte.ts index 14a312f11b..3b7c29e68a 100644 --- a/frontend/src/lib/stores/workflow-actions.svelte.ts +++ b/frontend/src/lib/stores/workflow-actions.svelte.ts @@ -26,6 +26,9 @@ export interface WorkflowActionsStore { readonly claimRepository: (owner: string, ref: ProviderRouteRef) => void; readonly releaseRepository: (owner: string) => void; readonly selectWorkflow: (ref: ProviderRouteRef, workflowId: string | null) => void; + readonly refreshCatalog: (ref: ProviderRouteRef, workflowId: string) => void; + readonly loadMoreRuns: (ref: ProviderRouteRef) => void; + readonly newDispatchCycle: (ref: ProviderRouteRef, workflowId: string) => void; readonly expandRun: (owner: string, ref: ProviderRouteRef, runId: string) => void; readonly collapseRun: (owner: string) => void; readonly dispatch: (input: WorkflowDispatchInput) => void; @@ -97,6 +100,51 @@ export function createWorkflowActionsStore(options: WorkflowActionsStoreOptions) ); } + function refreshCatalog(ref: ProviderRouteRef, workflowId: string): void { + if (!enabled) return; + runtime.runCommand( + Effect.gen(function* () { + const workflow = yield* WorkflowActionsWorkflow; + yield* workflow.refreshCatalog(ref, workflowId); + }), + { + operation: "refresh provider workflow catalog", + safeContext: { provider: ref.provider, owner: ref.owner, name: ref.name, workflowId }, + onFailure: () => {}, + }, + ); + } + + function loadMoreRuns(ref: ProviderRouteRef): void { + if (!enabled) return; + runtime.runCommand( + Effect.gen(function* () { + const workflow = yield* WorkflowActionsWorkflow; + yield* workflow.loadMoreRuns(ref); + }), + { + operation: "load more provider workflow runs", + safeContext: { provider: ref.provider, owner: ref.owner, name: ref.name }, + onFailure: () => {}, + }, + ); + } + + function newDispatchCycle(ref: ProviderRouteRef, workflowId: string): void { + if (!enabled) return; + runtime.runCommand( + Effect.gen(function* () { + const workflow = yield* WorkflowActionsWorkflow; + yield* workflow.newDispatchCycle(ref, workflowId); + }), + { + operation: "start new provider workflow dispatch cycle", + safeContext: { provider: ref.provider, owner: ref.owner, name: ref.name, workflowId }, + onFailure: () => {}, + }, + ); + } + function expandRun(owner: string, ref: ProviderRouteRef, runId: string): void { if (!enabled) return; jobOwners.get(owner)?.interrupt(); @@ -197,6 +245,9 @@ export function createWorkflowActionsStore(options: WorkflowActionsStoreOptions) claimRepository, releaseRepository, selectWorkflow, + refreshCatalog, + loadMoreRuns, + newDispatchCycle, expandRun, collapseRun, dispatch, diff --git a/internal/apiclient/generated/client.gen.go b/internal/apiclient/generated/client.gen.go index 15b7d0a892..45a19084cc 100644 --- a/internal/apiclient/generated/client.gen.go +++ b/internal/apiclient/generated/client.gen.go @@ -4031,6 +4031,7 @@ type RepoPreviewRow struct { // RepoRefResponse defines model for RepoRefResponse. type RepoRefResponse struct { Capabilities ProviderCapabilitiesResponse `json:"capabilities"` + DefaultBranch *string `json:"default_branch,omitempty"` Name string `json:"name"` Operations *RepoOperations `json:"operations,omitempty"` Owner string `json:"owner"` @@ -4699,6 +4700,7 @@ type WorkflowDispatchResponse struct { // Example: /api/v1/schemas/WorkflowDispatchResponse.json Schema *string `json:"$schema,omitempty"` Accepted bool `json:"accepted"` + Actor *string `json:"actor,omitempty"` LocatingRun bool `json:"locating_run"` Run *WorkflowRunResponse `json:"run,omitempty"` } @@ -5033,7 +5035,7 @@ type WorktreeSummary struct { // ListWorkflowRunsParams defines parameters for ListWorkflowRuns. type ListWorkflowRunsParams struct { - WorkflowId *string `form:"workflow_id,omitempty" json:"workflow_id,omitempty"` + WorkflowId string `form:"workflow_id" json:"workflow_id"` Event *string `form:"event,omitempty" json:"event,omitempty"` Branch *string `form:"branch,omitempty" json:"branch,omitempty"` Cursor *string `form:"cursor,omitempty" json:"cursor,omitempty"` @@ -5313,7 +5315,7 @@ type RenameFleetWorkspaceRuntimeSessionJSONBody map[string]interface{} // ListWorkflowRunsOnHostParams defines parameters for ListWorkflowRunsOnHost. type ListWorkflowRunsOnHostParams struct { - WorkflowId *string `form:"workflow_id,omitempty" json:"workflow_id,omitempty"` + WorkflowId string `form:"workflow_id" json:"workflow_id"` Event *string `form:"event,omitempty" json:"event,omitempty"` Branch *string `form:"branch,omitempty" json:"branch,omitempty"` Cursor *string `form:"cursor,omitempty" json:"cursor,omitempty"` @@ -16154,16 +16156,12 @@ func NewListWorkflowRunsRequest(server string, provider string, owner string, na // per the OpenAPI spec (e.g. "color=blue,black,brown"). var rawQueryFragments []string - if params.WorkflowId != nil { - - if queryFrag, err := runtime.StyleParamWithOptions("form", false, "workflow_id", *params.WorkflowId, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { - return nil, err - } else { - for _, qp := range strings.Split(queryFrag, "&") { - rawQueryFragments = append(rawQueryFragments, qp) - } + if queryFrag, err := runtime.StyleParamWithOptions("form", false, "workflow_id", params.WorkflowId, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { + return nil, err + } else { + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) } - } if params.Event != nil { @@ -20778,16 +20776,12 @@ func NewListWorkflowRunsOnHostRequest(server string, platformHost string, provid // per the OpenAPI spec (e.g. "color=blue,black,brown"). var rawQueryFragments []string - if params.WorkflowId != nil { - - if queryFrag, err := runtime.StyleParamWithOptions("form", false, "workflow_id", *params.WorkflowId, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { - return nil, err - } else { - for _, qp := range strings.Split(queryFrag, "&") { - rawQueryFragments = append(rawQueryFragments, qp) - } + if queryFrag, err := runtime.StyleParamWithOptions("form", false, "workflow_id", params.WorkflowId, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { + return nil, err + } else { + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) } - } if params.Event != nil { diff --git a/internal/github/client.go b/internal/github/client.go index 7e15fdc398..79ee97aa6c 100644 --- a/internal/github/client.go +++ b/internal/github/client.go @@ -3597,6 +3597,8 @@ func (c *liveClient) ListManualWorkflowRuns( result := platform.Page[*gh.WorkflowRun]{Items: runs.WorkflowRuns} if resp != nil && resp.NextPage > 0 { result.NextCursor = strconv.Itoa(resp.NextPage) + } else { + result.Exhausted = true } return result, nil } diff --git a/internal/github/client_test.go b/internal/github/client_test.go index ea745b9329..5295699081 100644 --- a/internal/github/client_test.go +++ b/internal/github/client_test.go @@ -2142,8 +2142,11 @@ func TestWorkflowTransportShape(t *testing.T) { }) require.NoError(t, err) assert.Equal(t, "3", page.NextCursor) - _, err = client.ListManualWorkflowRuns(t.Context(), "acme", "widgets", 42, platform.WorkflowRunQuery{PerPage: 10}) + assert.False(t, page.Exhausted) + finalPage, err := client.ListManualWorkflowRuns(t.Context(), "acme", "widgets", 42, platform.WorkflowRunQuery{PerPage: 10}) require.NoError(t, err) + assert.Empty(t, finalPage.NextCursor) + assert.True(t, finalPage.Exhausted) jobs, err := client.ListManualWorkflowJobs(t.Context(), "acme", "widgets", 99) require.NoError(t, err) require.Len(t, jobs, 2) diff --git a/internal/github/sync.go b/internal/github/sync.go index 0c345f18ff..676d4d0b07 100644 --- a/internal/github/sync.go +++ b/internal/github/sync.go @@ -2345,7 +2345,11 @@ func (p *gitHubClientProvider) ListWorkflowRuns( if err != nil { return platform.Page[platform.WorkflowRun]{}, err } - result := platform.Page[platform.WorkflowRun]{NextCursor: page.NextCursor, Items: make([]platform.WorkflowRun, 0, len(page.Items))} + result := platform.Page[platform.WorkflowRun]{ + NextCursor: page.NextCursor, + Exhausted: page.Exhausted, + Items: make([]platform.WorkflowRun, 0, len(page.Items)), + } for _, run := range page.Items { result.Items = append(result.Items, normalizeGitHubWorkflowRun(run)) } @@ -2405,17 +2409,20 @@ func (p *gitHubClientProvider) DispatchWorkflow( if err != nil { return platform.WorkflowDispatchResult{}, err } + actor, _ := p.AuthenticatedUser(ctx, ref) + result := platform.WorkflowDispatchResult{Actor: actor} details, err := client.DispatchManualWorkflow(ctx, ref.Owner, ref.Name, workflowID, gh.CreateWorkflowDispatchEventRequest{ Ref: request.Ref, Inputs: request.Inputs, }) if err != nil { - return platform.WorkflowDispatchResult{}, err + return result, err } - result := platform.WorkflowDispatchResult{Accepted: true, LocatingRun: details == nil || details.GetWorkflowRunID() == 0} + result.Accepted = true + result.LocatingRun = details == nil || details.GetWorkflowRunID() == 0 if !result.LocatingRun { run := platform.WorkflowRun{ ID: strconv.FormatInt(details.GetWorkflowRunID(), 10), - WorkflowID: request.WorkflowID, WebURL: details.GetHTMLURL(), + WorkflowID: request.WorkflowID, Actor: actor, WebURL: details.GetHTMLURL(), } result.Run = &run } diff --git a/internal/github/workflow_provider_test.go b/internal/github/workflow_provider_test.go index 45013e0fe8..c79428fd40 100644 --- a/internal/github/workflow_provider_test.go +++ b/internal/github/workflow_provider_test.go @@ -21,6 +21,7 @@ type workflowProviderFake struct { runs platform.Page[*gh.WorkflowRun] jobs []*gh.WorkflowJob dispatch *gh.WorkflowDispatchRunDetails + actor string definitionRefs []string calls []string } @@ -28,6 +29,9 @@ type workflowProviderFake struct { func (f *workflowProviderFake) GetRepository(context.Context, string, string) (*gh.Repository, error) { return &gh.Repository{Name: gh.Ptr("widgets"), DefaultBranch: gh.Ptr("trunk")}, nil } +func (f *workflowProviderFake) AuthenticatedViewerLogin(context.Context) (string, error) { + return f.actor, nil +} func (f *workflowProviderFake) ListRepositoryWorkflows(context.Context, string, string) ([]*gh.Workflow, error) { f.calls = append(f.calls, "workflows") return f.workflows, nil @@ -117,6 +121,14 @@ func TestGitHubWorkflowCapabilitiesAreIndependent(t *testing.T) { if !test.dispatch { _, err := provider.DispatchWorkflow(t.Context(), platform.RepoRef{}, platform.WorkflowDispatchRequest{}) require.ErrorIs(t, err, platform.ErrUnsupportedCapability) + } else { + result, err := provider.DispatchWorkflow( + t.Context(), + platform.RepoRef{Owner: "acme", Name: "widgets"}, + platform.WorkflowDispatchRequest{WorkflowID: "1", Ref: "main"}, + ) + require.NoError(t, err) + assert.Empty(t, result.Actor) } }) } @@ -187,7 +199,8 @@ func TestGitHubWorkflowProviderNormalizesRunsJobsAndDispatch(t *testing.T) { started := created.Add(time.Second) completed := created.Add(2 * time.Second) fake := &workflowProviderFake{ - runs: platform.Page[*gh.WorkflowRun]{NextCursor: "3", Items: []*gh.WorkflowRun{{ + actor: "maintainer", + runs: platform.Page[*gh.WorkflowRun]{NextCursor: "3", Exhausted: true, Items: []*gh.WorkflowRun{{ ID: gh.Ptr(int64(100)), WorkflowID: gh.Ptr(int64(42)), RunNumber: gh.Ptr(7), Name: gh.Ptr("Release"), Event: gh.Ptr("workflow_dispatch"), HeadBranch: gh.Ptr("main"), HeadSHA: gh.Ptr("abc"), Actor: &gh.User{Login: gh.Ptr("octocat")}, Status: gh.Ptr("completed"), Conclusion: gh.Ptr("success"), CreatedAt: &gh.Timestamp{Time: created}, UpdatedAt: &gh.Timestamp{Time: updated}, HTMLURL: gh.Ptr("https://example.test/runs/100"), @@ -199,7 +212,7 @@ func TestGitHubWorkflowProviderNormalizesRunsJobsAndDispatch(t *testing.T) { page, err := provider.ListWorkflowRuns(t.Context(), platform.RepoRef{Owner: "acme", Name: "widgets"}, platform.WorkflowRunQuery{WorkflowID: "42"}) require.NoError(t, err) assert.Equal(t, platform.Page[platform.WorkflowRun]{ - NextCursor: "3", + NextCursor: "3", Exhausted: true, Items: []platform.WorkflowRun{{ ID: "100", WorkflowID: "42", RunNumber: 7, Name: "Release", Event: "workflow_dispatch", Ref: "main", HeadSHA: "abc", Actor: "octocat", @@ -221,15 +234,17 @@ func TestGitHubWorkflowProviderNormalizesRunsJobsAndDispatch(t *testing.T) { result, err := provider.DispatchWorkflow(t.Context(), platform.RepoRef{Owner: "acme", Name: "widgets"}, platform.WorkflowDispatchRequest{WorkflowID: "42", Ref: "main"}) require.NoError(t, err) assert.Equal(t, platform.WorkflowDispatchResult{ + Actor: "maintainer", Accepted: true, Run: &platform.WorkflowRun{ - ID: "101", WorkflowID: "42", WebURL: "https://example.test/runs/101", + ID: "101", WorkflowID: "42", Actor: "maintainer", + WebURL: "https://example.test/runs/101", }, }, result) fake.dispatch = nil result, err = provider.DispatchWorkflow(t.Context(), platform.RepoRef{Owner: "acme", Name: "widgets"}, platform.WorkflowDispatchRequest{WorkflowID: "42", Ref: "main"}) require.NoError(t, err) - assert.Equal(t, platform.WorkflowDispatchResult{Accepted: true, LocatingRun: true}, result) + assert.Equal(t, platform.WorkflowDispatchResult{Accepted: true, LocatingRun: true, Actor: "maintainer"}, result) _, err = provider.DispatchWorkflow(t.Context(), platform.RepoRef{}, platform.WorkflowDispatchRequest{WorkflowID: "not-decimal"}) require.ErrorIs(t, err, platform.ErrInvalidArgument) } diff --git a/internal/platform/types.go b/internal/platform/types.go index 432cd414b3..a4246d0299 100644 --- a/internal/platform/types.go +++ b/internal/platform/types.go @@ -596,6 +596,7 @@ type WorkflowDispatchResult struct { Accepted bool LocatingRun bool Run *WorkflowRun + Actor string } type Capabilities struct { diff --git a/internal/server/httpapi/repository_resolver.go b/internal/server/httpapi/repository_resolver.go index 372ceb5ded..50d54a395a 100644 --- a/internal/server/httpapi/repository_resolver.go +++ b/internal/server/httpapi/repository_resolver.go @@ -304,6 +304,7 @@ func (r *RepositoryResolver) Ref(repo db.Repo) RepoRefResponse { RepoPath: repoPath, Owner: repo.Owner, Name: repo.Name, + DefaultBranch: repo.DefaultBranch, Capabilities: r.Capabilities(platform.Kind(provider), host), } } diff --git a/internal/server/httpapi/repository_types.go b/internal/server/httpapi/repository_types.go index deb58ebe65..863ddca220 100644 --- a/internal/server/httpapi/repository_types.go +++ b/internal/server/httpapi/repository_types.go @@ -77,6 +77,7 @@ type RepoRefResponse struct { RepoPath string `json:"repo_path"` Owner string `json:"owner"` Name string `json:"name"` + DefaultBranch string `json:"default_branch,omitempty"` Capabilities ProviderCapabilitiesResponse `json:"capabilities"` Operations *RepoOperations `json:"operations,omitempty"` } diff --git a/internal/server/workflowapi/routes.go b/internal/server/workflowapi/routes.go index 00b391e577..f8e18f33ef 100644 --- a/internal/server/workflowapi/routes.go +++ b/internal/server/workflowapi/routes.go @@ -119,7 +119,7 @@ func (h *Handler) listCatalogOnHost(ctx context.Context, input *hostRepositoryIn } func (h *Handler) listRuns(ctx context.Context, input *workflowRunsInput) (*runsOutput, error) { - if input.WorkflowID != "" && strings.TrimSpace(input.WorkflowID) == "" { + if strings.TrimSpace(input.WorkflowID) == "" { return nil, httpapi.Validation("query.workflow_id", "workflow_id must not be blank") } resolved, err := h.resolve(ctx, input.Provider, input.PlatformHost, input.Owner, input.Name, capabilityReadWorkflowRuns) @@ -262,7 +262,16 @@ func (h *Handler) dispatch(ctx context.Context, input *workflowDispatchInput) (* var dispatchErr error result, dispatchErr = dispatcher.DispatchWorkflow(ctx, ref, request) if dispatchErr != nil { - return httpapi.ProviderMutationProblem(dispatchErr, string(ref.Platform), ref.Host) + problem := httpapi.ProviderMutationProblem(dispatchErr, string(ref.Platform), ref.Host) + if result.Actor != "" { + if mutationProblem, ok := problem.(*httpapi.ProblemError); ok && mutationProblem.Code == httpapi.CodeMutationOutcomeUnknown { + if mutationProblem.Details == nil { + mutationProblem.Details = map[string]any{} + } + mutationProblem.Details["actor"] = result.Actor + } + } + return problem } return nil }) @@ -272,7 +281,9 @@ func (h *Handler) dispatch(ctx context.Context, input *workflowDispatchInput) (* if !matched { return nil, repositoryIdentityChangedProblem() } - response := WorkflowDispatchResponse{Accepted: result.Accepted, LocatingRun: result.LocatingRun} + response := WorkflowDispatchResponse{ + Accepted: result.Accepted, LocatingRun: result.LocatingRun, Actor: result.Actor, + } if result.Run != nil { run := workflowRun(*result.Run) response.Run = &run @@ -301,6 +312,10 @@ func dispatchUnavailableProblem(repo db.Repo, availability httpapi.OperationAvai details["retryAfter"] = availability.RetryAt } return httpapi.NewProblem(http.StatusTooManyRequests, httpapi.CodeRateLimited, availability.UnavailableReason, details) + case "missing_write_credential", "write_credential_error": + return httpapi.NewProblem(http.StatusForbidden, httpapi.CodeForbidden, availability.UnavailableReason, map[string]any{ + "reason": availability.Code, "provider": string(httpapi.ProviderKind(repo)), "platformHost": httpapi.ProviderHost(repo), + }) default: reason := availability.Code if reason == "" { diff --git a/internal/server/workflowapi/routes_test.go b/internal/server/workflowapi/routes_test.go index 8f0fd4a1be..17f7d76c23 100644 --- a/internal/server/workflowapi/routes_test.go +++ b/internal/server/workflowapi/routes_test.go @@ -25,6 +25,7 @@ import ( type workflowTestProvider struct { caps platform.Capabilities catalog []platform.WorkflowDefinition + authenticatedUser string environments []platform.WorkflowEnvironment runs platform.Page[platform.WorkflowRun] jobs []platform.WorkflowRunJob @@ -40,6 +41,9 @@ type workflowTestProvider struct { func (p *workflowTestProvider) Platform() platform.Kind { return platform.KindGitHub } func (p *workflowTestProvider) Host() string { return platform.DefaultGitHubHost } func (p *workflowTestProvider) Capabilities() platform.Capabilities { return p.caps } +func (p *workflowTestProvider) AuthenticatedUser(context.Context, platform.RepoRef) (string, error) { + return p.authenticatedUser, nil +} func (p *workflowTestProvider) ListManualWorkflows(context.Context, platform.RepoRef) ([]platform.WorkflowDefinition, error) { if p.onCatalog != nil { p.onCatalog() } return p.catalog, p.catalogErr @@ -66,8 +70,12 @@ func workflowFixture(t *testing.T, provider *workflowTestProvider, operation htt database := dbtest.Open(t) identity := db.GitHubRepoIdentity(platform.DefaultGitHubHost, "acme", "widget") identity.PlatformRepoID = "R_widget" - _, err := database.UpsertRepo(t.Context(), identity) + repoID, err := database.UpsertRepo(t.Context(), identity) require.NoError(t, err) + require.NoError(t, database.UpdateRepoProviderMetadata(t.Context(), repoID, db.RepoProviderMetadata{ + PlatformRepoID: "R_widget", + DefaultBranch: "trunk", + })) registry, err := platform.NewRegistry(provider) require.NoError(t, err) syncer := ghclient.NewSyncerWithRegistry(registry, database, nil, nil, time.Minute, nil, nil) @@ -134,6 +142,7 @@ func TestWorkflowCatalogRoutesUseStableRepositoryIdentity(t *testing.T) { repo := body["repo"].(map[string]any) assert.Equal(t, "R_widget", repo["platform_repo_id"]) assert.Equal(t, "acme/widget", repo["repo_path"]) + assert.Equal(t, "trunk", repo["default_branch"]) assert.Equal(t, true, repo["operations"].(map[string]any)["dispatch_workflow"].(map[string]any)["available"]) workflow := body["workflows"].([]any)[0].(map[string]any) assert.Equal(t, "definition-v1", workflow["definition_sha"]) @@ -142,6 +151,31 @@ func TestWorkflowCatalogRoutesUseStableRepositoryIdentity(t *testing.T) { assert.Equal(t, "production", body["environments"].([]any)[0].(map[string]any)["name"]) } } +func TestWorkflowRunRoutesRequireWorkflowIDInSchema(t *testing.T) { + mux := http.NewServeMux() + config := huma.DefaultConfig("workflow schema test", "0") + config.OpenAPIPath, config.DocsPath, config.SchemasPath = "", "", "" + api := humago.NewWithPrefix(mux, "/api/v1", config) + New(Deps{}).Register(api) + + for _, path := range []string{ + "/actions/{provider}/{owner}/{name}/runs", + "/host/{platform_host}/actions/{provider}/{owner}/{name}/runs", + } { + operation := api.OpenAPI().Paths[path].Get + require.NotNil(t, operation) + var workflowID *huma.Param + for _, parameter := range operation.Parameters { + if parameter.Name == "workflow_id" && parameter.In == "query" { + workflowID = parameter + break + } + } + require.NotNil(t, workflowID) + assert.True(t, workflowID.Required) + } +} + func TestWorkflowRunsAndJobsPreserveProviderContracts(t *testing.T) { started := time.Date(2026, 8, 27, 14, 30, 0, 0, time.FixedZone("CEST", 2*60*60)) @@ -173,13 +207,12 @@ func TestWorkflowRoutesRejectUnsupportedAndMalformedIdentifiers(t *testing.T) { status, body := workflowRequest(t, mux, http.MethodGet, "/actions/github/acme/widget/workflows", nil) assert.Equal(t, http.StatusConflict, status) assert.Equal(t, "unsupportedCapability", body["code"]) - assert.Equal(t, "read_workflows", body["details"].(map[string]any)["capability"]) - - provider.caps = platform.Capabilities{ReadWorkflows: true} - status, body = workflowRequest(t, mux, http.MethodGet, "/actions/github/acme/widget/runs", nil) + status, body = workflowRequest(t, mux, http.MethodGet, "/actions/github/acme/widget/runs?workflow_id=release.yml", nil) assert.Equal(t, http.StatusConflict, status) assert.Equal(t, "read_workflow_runs", body["details"].(map[string]any)["capability"]) + provider.caps = platform.Capabilities{ReadWorkflows: true} + status, body = workflowRequest( t, mux, @@ -204,6 +237,14 @@ func TestWorkflowRoutesRejectUnsupportedAndMalformedIdentifiers(t *testing.T) { assert.Equal(t, "validationError", body["code"]) assert.NotEmpty(t, body["details"].(map[string]any)["field"]) } + for _, path := range []string{ + "/actions/github/acme/widget/runs", + "/host/github.com/actions/github/acme/widget/runs", + } { + status, body = workflowRequest(t, mux, http.MethodGet, path, nil) + assert.Equal(t, http.StatusUnprocessableEntity, status) + assert.Equal(t, "validationError", body["code"]) + } status, body = workflowRequest( t, mux, @@ -295,38 +336,80 @@ func TestWorkflowDispatchEnforcesInputLimitsAndOperationGate(t *testing.T) { assert.Equal(t, "rateLimited", body["code"]) assert.Empty(t, provider.dispatches) } +func TestWorkflowDispatchMapsWriteCredentialAvailabilityToForbidden(t *testing.T) { + for _, reason := range []string{"missing_write_credential", "write_credential_error"} { + t.Run(reason, func(t *testing.T) { + provider := &workflowTestProvider{ + caps: platform.Capabilities{ReadWorkflows: true, WorkflowDispatch: true}, + catalog: []platform.WorkflowDefinition{workflowDefinitionFixture()}, + } + mux, _ := workflowFixture(t, provider, httpapi.OperationAvailability{ + Code: reason, UnavailableReason: "Personal write credential unavailable.", + }) + status, body := workflowRequest( + t, + mux, + http.MethodPost, + "/actions/github/acme/widget/workflows/release.yml/dispatch", + map[string]any{ + "ref": "main", + "expected_definition_sha": "definition-v1", + "inputs": map[string]any{"version": "1"}, + }, + ) + assert.Equal(t, http.StatusForbidden, status) + assert.Equal(t, "forbidden", body["code"]) + assert.Equal(t, reason, body["details"].(map[string]any)["reason"]) + assert.Equal(t, "github", body["details"].(map[string]any)["provider"]) + assert.Equal(t, "github.com", body["details"].(map[string]any)["platformHost"]) + assert.Empty(t, provider.dispatches) + }) + } +} + func TestWorkflowDispatchMapsProviderRejectionAndMutationUncertainty(t *testing.T) { for _, test := range []struct { - name string - err error - wantStatus int - wantCode string + name string + err error + wantStatus int + wantCode string + wantActor bool }{ {name: "typed rejection", err: &platform.Error{Code: platform.ErrCodeInvalidArgument, Provider: platform.KindGitHub, PlatformHost: platform.DefaultGitHubHost, Field: "ref", Err: errors.New("ref rejected")}, wantStatus: 400, wantCode: "badRequest"}, - {name: "transport uncertainty", err: errors.New("connection reset"), wantStatus: 502, wantCode: "mutationOutcomeUnknown"}, + {name: "transport uncertainty", err: errors.New("connection reset"), wantStatus: 502, wantCode: "mutationOutcomeUnknown", wantActor: true}, } { t.Run(test.name, func(t *testing.T) { - provider := &workflowTestProvider{caps: platform.Capabilities{ReadWorkflows: true, WorkflowDispatch: true}, catalog: []platform.WorkflowDefinition{workflowDefinitionFixture()}, dispatchErr: test.err} + provider := &workflowTestProvider{ + caps: platform.Capabilities{ReadWorkflows: true, WorkflowDispatch: true, ReadAuthenticatedUser: true}, + catalog: []platform.WorkflowDefinition{workflowDefinitionFixture()}, + dispatch: platform.WorkflowDispatchResult{Actor: "maintainer"}, + dispatchErr: test.err, + authenticatedUser: "maintainer", + } mux, _ := workflowFixture(t, provider, httpapi.OperationAvailability{Available: true}) status, body := workflowRequest(t, mux, http.MethodPost, "/actions/github/acme/widget/workflows/release.yml/dispatch", map[string]any{"ref": "main", "expected_definition_sha": "definition-v1", "inputs": map[string]any{"version": "1"}}) assert.Equal(t, test.wantStatus, status) assert.Equal(t, test.wantCode, body["code"]) + if test.wantActor { + assert.Equal(t, "maintainer", body["details"].(map[string]any)["actor"]) + } require.Len(t, provider.dispatches, 1) }) } } -func TestWorkflowDispatchResponsePreservesLocatingAndConcreteRun(t *testing.T) { +func TestWorkflowDispatchResponsePreservesLocatingConcreteRunAndActor(t *testing.T) { for _, result := range []platform.WorkflowDispatchResult{ - {Accepted: true, LocatingRun: true}, - {Accepted: true, Run: &platform.WorkflowRun{ID: "run-9", WorkflowID: "release.yml", CreatedAt: time.Date(2026, 8, 27, 12, 0, 0, 0, time.UTC)}}, + {Accepted: true, LocatingRun: true, Actor: "maintainer"}, + {Accepted: true, Actor: "maintainer", Run: &platform.WorkflowRun{ID: "run-9", WorkflowID: "release.yml", CreatedAt: time.Date(2026, 8, 27, 12, 0, 0, 0, time.UTC)}}, } { provider := &workflowTestProvider{caps: platform.Capabilities{ReadWorkflows: true, WorkflowDispatch: true}, catalog: []platform.WorkflowDefinition{workflowDefinitionFixture()}, dispatch: result} mux, _ := workflowFixture(t, provider, httpapi.OperationAvailability{Available: true}) status, body := workflowRequest(t, mux, http.MethodPost, "/host/github.com/actions/github/acme/widget/workflows/release.yml/dispatch", map[string]any{"ref": "main", "expected_definition_sha": "definition-v1", "inputs": map[string]any{"version": "1"}}) require.Equal(t, http.StatusAccepted, status) assert.Equal(t, result.LocatingRun, body["locating_run"]) + assert.Equal(t, "maintainer", body["actor"]) if result.Run != nil { assert.Equal(t, "run-9", body["run"].(map[string]any)["id"]) } } } diff --git a/internal/server/workflowapi/types.go b/internal/server/workflowapi/types.go index a59825c304..0a3daa1526 100644 --- a/internal/server/workflowapi/types.go +++ b/internal/server/workflowapi/types.go @@ -24,7 +24,7 @@ type workflowRunsInput struct { PlatformHost string Owner string `path:"owner"` Name string `path:"name"` - WorkflowID string `query:"workflow_id"` + WorkflowID string `query:"workflow_id" required:"true"` Event string `query:"event"` Branch string `query:"branch"` Cursor string `query:"cursor"` @@ -36,7 +36,7 @@ type hostWorkflowRunsInput struct { PlatformHost string `path:"platform_host"` Owner string `path:"owner"` Name string `path:"name"` - WorkflowID string `query:"workflow_id"` + WorkflowID string `query:"workflow_id" required:"true"` Event string `query:"event"` Branch string `query:"branch"` Cursor string `query:"cursor"` @@ -166,6 +166,7 @@ type WorkflowJobsResponse struct { type WorkflowDispatchResponse struct { Accepted bool `json:"accepted"` LocatingRun bool `json:"locating_run"` + Actor string `json:"actor,omitempty"` Run *WorkflowRunResponse `json:"run,omitempty"` } From ee5c91e5feec966f6c1eee84eda7478d7583d1ae Mon Sep 17 00:00:00 2001 From: Max Mill Date: Fri, 28 Aug 2026 22:54:28 +0200 Subject: [PATCH 24/41] fix: satisfy workflow Actions frontend checks --- .../App.workflow-actions.browser.svelte.ts | 76 +++--- ...lDetail.workflow-actions.browser.svelte.ts | 108 ++++---- frontend/src/lib/api/provider-routes.ts | 6 +- frontend/src/lib/app/app-stores.test.ts | 16 +- .../lib/components/actions/ActionsPage.svelte | 2 +- .../components/actions/ActionsPage.test.ts | 242 ++++++++++-------- .../actions/WorkflowDispatchDialog.test.ts | 93 +++++-- .../actions/WorkflowDispatchForm.svelte | 20 +- .../actions/WorkflowDispatchForm.test.ts | 126 +++++++-- .../components/actions/WorkflowRunList.svelte | 2 +- .../actions/WorkflowRunList.test.ts | 48 +++- .../lib/components/detail/PullDetail.svelte | 2 +- .../lib/components/detail/PullDetail.test.ts | 86 +++---- .../stores/workflow-actions-workflow.test.ts | 215 +++++++++------- .../lib/stores/workflow-actions-workflow.ts | 125 +++------ .../stores/workflow-actions.svelte.test.ts | 20 +- frontend/tests/e2e-full/actions.spec.ts | 6 +- 17 files changed, 693 insertions(+), 500 deletions(-) diff --git a/frontend/src/App.workflow-actions.browser.svelte.ts b/frontend/src/App.workflow-actions.browser.svelte.ts index 782d4b5890..3ac18a811a 100644 --- a/frontend/src/App.workflow-actions.browser.svelte.ts +++ b/frontend/src/App.workflow-actions.browser.svelte.ts @@ -8,18 +8,13 @@ import { resetKeyboardModuleState, type MountedBrowserApp, } from "./test/browserAppHarness.js"; -import { - jsonResponse, - mockSettings, - type MockRouteOverride, -} from "./test/mockApiFetch.js"; +import { jsonResponse, mockSettings, type MockRouteOverride } from "./test/mockApiFetch.js"; const WAIT = 10_000; let actionsModeEnabled = true; const capable = { - read_repositories: true, read_merge_requests: true, read_issues: true, @@ -184,12 +179,14 @@ describe("opt-in workflow Actions route", () => { await expect.element(page.getByRole("heading", { name: "Actions" })).toBeVisible(); await expect.element(page.getByRole("button", { name: /Deploy/ })).toBeVisible(); await vi.waitFor(() => { - expect(actionReadPaths(mounted!)).toEqual(expect.arrayContaining([ - "/api/v1/actions/github/acme/widgets/workflows", - ])); + expect(actionReadPaths(mounted!)).toEqual( + expect.arrayContaining(["/api/v1/actions/github/acme/widgets/workflows"]), + ); }, WAIT); expect(actionReadPaths(mounted).some((pathname) => pathname.includes("/legacy/"))).toBe(false); - await expect.element(page.getByRole("note", { name: "acme/legacy does not support workflow Actions" })).toBeVisible(); + await expect + .element(page.getByRole("note", { name: "acme/legacy does not support workflow Actions" })) + .toBeVisible(); }); it("releases the mounted workspace and redirects when a settings update disables Actions", async () => { @@ -203,9 +200,9 @@ describe("opt-in workflow Actions route", () => { await vi.waitFor(() => expect(window.location.pathname).toBe("/"), WAIT); expect(document.querySelector(".actions-page")).toBeNull(); expect(document.querySelector(".kit-top-bar__tab[aria-current='page']")?.textContent).toContain("Activity"); - expect( - Array.from(document.querySelectorAll(".kit-top-bar__tab"), (tab) => tab.textContent?.trim()), - ).not.toContain("Actions"); + expect(Array.from(document.querySelectorAll(".kit-top-bar__tab"), (tab) => tab.textContent?.trim())).not.toContain( + "Actions", + ); }); it("keeps repository, workflow, dispatch, and runs regions in semantic order when narrow", async () => { @@ -228,25 +225,26 @@ describe("opt-in workflow Actions route", () => { it("keeps populated run metadata and provider actions reachable without narrow horizontal overflow", async () => { await page.viewport(520, 800); const longRun: MockRouteOverride = (request) => { - if (request.method !== "GET" - || request.url.pathname !== "/api/v1/actions/github/acme/widgets/runs") return null; + if (request.method !== "GET" || request.url.pathname !== "/api/v1/actions/github/acme/widgets/runs") return null; return jsonResponse({ repo: { ...summary("widgets", true).repo, default_branch: "trunk" }, exhausted: true, - items: [{ - actor: "maintainer-with-an-intentionally-long-provider-identity", - conclusion: "success", - created_at: "2026-08-27T12:30:00Z", - event: "workflow_dispatch", - head_sha: "0123456789abcdef0123456789abcdef01234567", - id: "long-run", - name: "Release production assets with a deliberately long workflow name", - ref: "feature/an-intentionally-long-reference-that-must-not-expand-the-page", - run_number: 99, - status: "completed", - web_url: "https://github.com/acme/widgets/actions/runs/99", - workflow_id: "deploy.yml", - }], + items: [ + { + actor: "maintainer-with-an-intentionally-long-provider-identity", + conclusion: "success", + created_at: "2026-08-27T12:30:00Z", + event: "workflow_dispatch", + head_sha: "0123456789abcdef0123456789abcdef01234567", + id: "long-run", + name: "Release production assets with a deliberately long workflow name", + ref: "feature/an-intentionally-long-reference-that-must-not-expand-the-page", + run_number: 99, + status: "completed", + web_url: "https://github.com/acme/widgets/actions/runs/99", + workflow_id: "deploy.yml", + }, + ], }); }; mounted = await mountBrowserApp("/actions", { overrides: [longRun, actionsFixtures()] }); @@ -259,20 +257,14 @@ describe("opt-in workflow Actions route", () => { const row = document.querySelector(".run-row")!; const disclosure = document.querySelector(".run-disclosure")!; const providerLink = row.querySelector("a")!; - const metadata = row.querySelectorAll( - ".run-ref, .run-actor, .run-time, .run-status", - ); + const metadata = row.querySelectorAll(".run-ref, .run-actor, .run-time, .run-status"); expect(metadata).toHaveLength(4); expect(pageElement.scrollWidth).toBeLessThanOrEqual(pageElement.clientWidth); expect(row.scrollWidth).toBeLessThanOrEqual(row.clientWidth); expect(disclosure.scrollWidth).toBeLessThanOrEqual(disclosure.clientWidth); - expect(providerLink.getBoundingClientRect().right).toBeLessThanOrEqual( - row.getBoundingClientRect().right, - ); + expect(providerLink.getBoundingClientRect().right).toBeLessThanOrEqual(row.getBoundingClientRect().right); for (const item of metadata) { - expect(item.getBoundingClientRect().right).toBeLessThanOrEqual( - disclosure.getBoundingClientRect().right, - ); + expect(item.getBoundingClientRect().right).toBeLessThanOrEqual(disclosure.getBoundingClientRect().right); } }, WAIT); }); @@ -297,8 +289,12 @@ describe("opt-in workflow Actions route", () => { expect(document.querySelector(".repository-rail")).toBeNull(); expect(getComputedStyle(layout).display).toBe("grid"); expect(Math.round(workflows.getBoundingClientRect().top)).toBe(Math.round(workspace.getBoundingClientRect().top)); - expect(Math.round(workflows.getBoundingClientRect().bottom)).toBe(Math.round(layout.getBoundingClientRect().bottom)); - expect(Math.round(workspace.getBoundingClientRect().bottom)).toBe(Math.round(layout.getBoundingClientRect().bottom)); + expect(Math.round(workflows.getBoundingClientRect().bottom)).toBe( + Math.round(layout.getBoundingClientRect().bottom), + ); + expect(Math.round(workspace.getBoundingClientRect().bottom)).toBe( + Math.round(layout.getBoundingClientRect().bottom), + ); }, WAIT); }); }); diff --git a/frontend/src/PullDetail.workflow-actions.browser.svelte.ts b/frontend/src/PullDetail.workflow-actions.browser.svelte.ts index 76e3b5fec7..88522c7c07 100644 --- a/frontend/src/PullDetail.workflow-actions.browser.svelte.ts +++ b/frontend/src/PullDetail.workflow-actions.browser.svelte.ts @@ -7,7 +7,6 @@ import type { GeneratedClient } from "./lib/api/generated-api.js"; import type { PullDetail } from "./lib/api/types.js"; import { type OwnedAppRuntime } from "./lib/app/runtime.js"; import { ACTIONS_KEY, NAVIGATE_KEY, STORES_KEY, UI_CONFIG_KEY } from "./lib/context.js"; -import PullDetailComponent from "./lib/components/detail/PullDetail.svelte"; import PullDetailTestHarness from "./lib/components/detail/PullDetailTestHarness.svelte"; import { createDetailActivityViewStore } from "./lib/stores/detail-activity-view.svelte.js"; import { createSettingsStore } from "./lib/stores/settings.svelte.js"; @@ -27,7 +26,7 @@ const workflow = { } as const; function pullDetail(): PullDetail { - const capabilities = { + const capabilities: PullDetail["repo"]["capabilities"] = { read_repositories: true, read_merge_requests: true, read_issues: true, @@ -61,33 +60,42 @@ function pullDetail(): PullDetail { thread_reply: false, thread_resolve: false, supported_review_actions: [], - } as const; - const operations = { + }; + const unavailable = { available: false } as const; + const operations: NonNullable = { + add_comment: unavailable, + add_label: unavailable, + apply_review_suggestion: unavailable, + approve_workflow: unavailable, + close_issue: unavailable, close_pr: { available: true }, + create_issue: unavailable, + delete_comment: unavailable, dispatch_workflow: { available: true }, + edit_comment: unavailable, + mark_draft: unavailable, + mark_ready_for_review: unavailable, merge_pr: { available: true }, + remove_label: unavailable, + reopen_issue: unavailable, + reopen_pr: unavailable, + reply_review_thread: unavailable, + resolve_review_thread: unavailable, + review_draft: unavailable, + set_assignees: unavailable, + set_reviewers: unavailable, submit_review: { available: true }, - } as const; - const repo = { - ID: 1, - Owner: "acme", - Name: "widgets", - Host: "github.com", - PlatformHost: "github.com", - Platform: "github", - URL: "https://github.com/acme/widgets", - DefaultBranch: "main", - IsArchived: false, - AllowSquashMerge: true, - AllowMergeCommit: false, - AllowRebaseMerge: false, - capabilities, - operations, + update_content: unavailable, + }; + const repo: PullDetail["repo"] = { provider: "github", platform_host: "github.com", owner: "acme", name: "widgets", repo_path: "acme/widgets", + default_branch: "main", + capabilities, + operations, }; return { detail_loaded: true, @@ -103,8 +111,7 @@ function pullDetail(): PullDetail { repo_owner: "acme", repo_name: "widgets", warnings: [], - workflow_approval: { count: 0, required: false, runs: [] }, - workspace: undefined, + workflow_approval: { checked: false, count: 0, required: false }, worktree_links: [], repo, merge_request: { @@ -125,6 +132,8 @@ function pullDetail(): PullDetail { BaseBranch: "main", HeadRepoCloneURL: "https://github.com/acme/widgets.git", Additions: 12, + FilesChanged: 3, + MergeCommitSHA: "", Deletions: 2, CommentCount: 0, ReviewDecision: "APPROVED", @@ -157,12 +166,15 @@ function visibleActionsTriggers(): HTMLButtonElement[] { } function visibleButton(name: string): HTMLButtonElement | null { - return Array.from(document.querySelectorAll("button")).find((button) => - button.textContent?.trim() === name - && !button.closest("[aria-hidden='true']") - && getComputedStyle(button).display !== "none" - && button.offsetParent !== null - ) ?? null; + return ( + Array.from(document.querySelectorAll("button")).find( + (button) => + button.textContent?.trim() === name && + !button.closest("[aria-hidden='true']") && + getComputedStyle(button).display !== "none" && + button.offsetParent !== null, + ) ?? null + ); } afterEach(async () => { @@ -192,17 +204,22 @@ describe("PullDetail provider workflow action geometry", () => { settings.setModeVisibility({ ...settings.getModeVisibility(), actions: true }); settings.setDetailSettings({ initial_timeline_entry_limit: 250 }); const workflowActions = { - claimRepository: vi.fn(() => { catalogClaimed = true; }), - releaseRepository: vi.fn(() => { catalogClaimed = false; }), + claimRepository: vi.fn(() => { + catalogClaimed = true; + }), + releaseRepository: vi.fn(() => { + catalogClaimed = false; + }), selectWorkflow: vi.fn(), + refreshCatalog: vi.fn(), + loadMoreRuns: vi.fn(), + newDispatchCycle: vi.fn(), expandRun: vi.fn(), collapseRun: vi.fn(), dispatch: vi.fn(), setEnabled: vi.fn(), getSnapshot: vi.fn(() => null), - getCatalog: () => catalogClaimed - ? { repo: detail.repo, environments: [], workflows: [workflow] } - : null, + getCatalog: () => (catalogClaimed ? { repo: detail.repo, environments: [], workflows: [workflow] } : null), getEnvironments: () => [], getSelectedWorkflow: vi.fn(() => null), getRuns: vi.fn(() => []), @@ -258,14 +275,17 @@ describe("PullDetail provider workflow action geometry", () => { }, }, context: new Map([ - [STORES_KEY, { - detail: detailStore, - pulls: { loadPulls: vi.fn() }, - activity: { loadActivity: vi.fn() }, - detailActivityView: createDetailActivityViewStore(), - settings, - workflowActions, - }], + [ + STORES_KEY, + { + detail: detailStore, + pulls: { loadPulls: vi.fn() }, + activity: { loadActivity: vi.fn() }, + detailActivityView: createDetailActivityViewStore(), + settings, + workflowActions, + }, + ], [ACTIONS_KEY, { pull: [] }], [UI_CONFIG_KEY, { hideStar: true }], [NAVIGATE_KEY, vi.fn()], @@ -279,7 +299,7 @@ describe("PullDetail provider workflow action geometry", () => { expect(visibleButton("Close")).not.toBeNull(); }, WAIT); - await visibleActionsTriggers()[0]!.click(); + visibleActionsTriggers()[0]!.click(); await vi.waitFor(() => { const workflowMenu = document.querySelector(".workflow-actions-menu"); expect(workflowMenu).not.toBeNull(); @@ -290,7 +310,7 @@ describe("PullDetail provider workflow action geometry", () => { expect(visibleButton("Close")).not.toBeNull(); }, WAIT); - await visibleActionsTriggers()[0]!.click(); + visibleActionsTriggers()[0]!.click(); wrapper.style.width = "180px"; await vi.waitFor(() => { expect(document.querySelector(".pull-detail-content--actions-menu")).not.toBeNull(); @@ -300,7 +320,7 @@ describe("PullDetail provider workflow action geometry", () => { expect(visibleButton("Close")).toBeNull(); }, WAIT); - await visibleActionsTriggers()[0]!.click(); + visibleActionsTriggers()[0]!.click(); await vi.waitFor(() => { const menu = document.querySelector(".actions-menu-popover"); expect(menu).not.toBeNull(); diff --git a/frontend/src/lib/api/provider-routes.ts b/frontend/src/lib/api/provider-routes.ts index c9356e40b2..aaa91be009 100644 --- a/frontend/src/lib/api/provider-routes.ts +++ b/frontend/src/lib/api/provider-routes.ts @@ -127,11 +127,7 @@ export function providerItemPath(kind: "pulls" | "issues", ref: ProviderRouteRef return `/${kind}/{provider}/{owner}/{name}/{number}${suffix}`; } -type ActionsSuffix = - | "/workflows" - | "/runs" - | "/runs/{run_id}/jobs" - | "/workflows/{workflow_id}/dispatch"; +type ActionsSuffix = "/workflows" | "/runs" | "/runs/{run_id}/jobs" | "/workflows/{workflow_id}/dispatch"; type ActionsPath = | `/actions/{provider}/{owner}/{name}${S}` diff --git a/frontend/src/lib/app/app-stores.test.ts b/frontend/src/lib/app/app-stores.test.ts index 8fe1510b32..6f661316ae 100644 --- a/frontend/src/lib/app/app-stores.test.ts +++ b/frontend/src/lib/app/app-stores.test.ts @@ -28,12 +28,14 @@ describe("app store composition", () => { expect(composition.stores.issues.getIssues()).toEqual([]); expect(composition.stores.activity.getActivityItems()).toEqual([]); expect(composition.stores.grouping.getGroupByRepo()).toBe(true); - expect(composition.stores.workflowActions.getRuns({ - provider: "github", - platformHost: "github.com", - owner: "acme", - name: "widgets", - repoPath: "acme/widgets", - })).toEqual([]); + expect( + composition.stores.workflowActions.getRuns({ + provider: "github", + platformHost: "github.com", + owner: "acme", + name: "widgets", + repoPath: "acme/widgets", + }), + ).toEqual([]); }); }); diff --git a/frontend/src/lib/components/actions/ActionsPage.svelte b/frontend/src/lib/components/actions/ActionsPage.svelte index 4d06297f12..13574ffe0a 100644 --- a/frontend/src/lib/components/actions/ActionsPage.svelte +++ b/frontend/src/lib/components/actions/ActionsPage.svelte @@ -132,7 +132,7 @@ if (!dispatch) return { kind: "idle" }; if (dispatch.kind === "pending") return { kind: "pending" }; if (dispatch.kind === "locating") return { kind: "locating" }; - if (dispatch.kind === "succeeded") return { kind: "succeeded", run: dispatch.run }; + if (dispatch.kind === "succeeded") return dispatch.run === undefined ? { kind: "succeeded" } : { kind: "succeeded", run: dispatch.run }; if ( dispatch.kind === "failed" && dispatch.error._tag === "ApiProblemError" diff --git a/frontend/src/lib/components/actions/ActionsPage.test.ts b/frontend/src/lib/components/actions/ActionsPage.test.ts index 827f33b1a7..ba63bc5296 100644 --- a/frontend/src/lib/components/actions/ActionsPage.test.ts +++ b/frontend/src/lib/components/actions/ActionsPage.test.ts @@ -110,16 +110,18 @@ function workflowFixtures(): MockRouteOverride { return jsonResponse({ repo: { ...repoSummary(name).repo, default_branch: "trunk" }, environments: [{ name: "production" }], - workflows: [{ - id: `${name}-deploy.yml`, - name: `${name} deploy`, - path: `.github/workflows/${name}-deploy.yml`, - state: "active", - available: true, - definition_sha: `${name}-definition`, - inputs: [], - web_url: `https://github.com/acme/${name}/actions/workflows/${name}-deploy.yml`, - }], + workflows: [ + { + id: `${name}-deploy.yml`, + name: `${name} deploy`, + path: `.github/workflows/${name}-deploy.yml`, + state: "active", + available: true, + definition_sha: `${name}-definition`, + inputs: [], + web_url: `https://github.com/acme/${name}/actions/workflows/${name}-deploy.yml`, + }, + ], }); } const runs = request.url.pathname.match(/^\/api\/v1\/actions\/github\/acme\/([^/]+)\/runs$/); @@ -129,19 +131,21 @@ function workflowFixtures(): MockRouteOverride { return jsonResponse({ repo: { ...repoSummary(name).repo, default_branch: "trunk" }, exhausted: true, - items: [{ - actor: "octocat", - conclusion: "success", - created_at: "2026-08-26T12:30:00Z", - event: "workflow_dispatch", - head_sha: "olderabcdef", - id: `${name}-run-older`, - name: `${name} deploy`, - ref: "release/v1", - run_number: 5, - status: "completed", - workflow_id: `${name}-deploy.yml`, - }], + items: [ + { + actor: "octocat", + conclusion: "success", + created_at: "2026-08-26T12:30:00Z", + event: "workflow_dispatch", + head_sha: "olderabcdef", + id: `${name}-run-older`, + name: `${name} deploy`, + ref: "release/v1", + run_number: 5, + status: "completed", + workflow_id: `${name}-deploy.yml`, + }, + ], }); } return jsonResponse({ @@ -183,13 +187,15 @@ function workflowFixtures(): MockRouteOverride { const runId = jobs[2]!; return jsonResponse({ repo: repoSummary(jobs[1]!).repo, - items: [{ - id: `${runId}-job`, - name: runId.endsWith("run-2") ? "Verify" : "Publish", - status: "completed", - conclusion: "success", - steps: [], - }], + items: [ + { + id: `${runId}-job`, + name: runId.endsWith("run-2") ? "Verify" : "Publish", + status: "completed", + conclusion: "success", + steps: [], + }, + ], }); } return null; @@ -292,22 +298,25 @@ describe("ActionsPage", () => { await fireEvent.click(await screen.findByRole("button", { name: "Load more runs" })); expect(await screen.findByRole("button", { name: /Run 5 alpha deploy/ })).toBeTruthy(); expect((screen.getByRole("textbox", { name: "Git ref" }) as HTMLInputElement).value).toBe("trunk"); - const olderRequest = api.requests.find((request) => - request.url.pathname.endsWith("/actions/github/acme/alpha/runs") - && request.url.searchParams.get("cursor") === "older-page" + const olderRequest = api.requests.find( + (request) => + request.url.pathname.endsWith("/actions/github/acme/alpha/runs") && + request.url.searchParams.get("cursor") === "older-page", ); expect(olderRequest).toBeTruthy(); }); it("renders an empty runs read failure as an error instead of a successful empty state", async () => { const runsFailure: MockRouteOverride = (request) => { - if (request.method !== "GET" - || request.url.pathname !== "/api/v1/actions/github/acme/alpha/runs") return null; - return jsonResponse({ - code: "internalError", - detail: "Recent workflow runs could not be loaded.", - status: 500, - }, 500); + if (request.method !== "GET" || request.url.pathname !== "/api/v1/actions/github/acme/alpha/runs") return null; + return jsonResponse( + { + code: "internalError", + detail: "Recent workflow runs could not be loaded.", + status: 500, + }, + 500, + ); }; api = createMockApiFetch([runsFailure, workflowFixtures()]); globalThis.fetch = api.fetch; @@ -323,13 +332,19 @@ describe("ActionsPage", () => { it("keeps retained runs visible while surfacing a lazy jobs read failure as degraded", async () => { const jobsFailure: MockRouteOverride = (request) => { - if (request.method !== "GET" - || request.url.pathname !== "/api/v1/actions/github/acme/alpha/runs/alpha-run-1/jobs") return null; - return jsonResponse({ - code: "internalError", - detail: "Workflow jobs could not be loaded.", - status: 500, - }, 500); + if ( + request.method !== "GET" || + request.url.pathname !== "/api/v1/actions/github/acme/alpha/runs/alpha-run-1/jobs" + ) + return null; + return jsonResponse( + { + code: "internalError", + detail: "Workflow jobs could not be loaded.", + status: 500, + }, + 500, + ); }; api = createMockApiFetch([jobsFailure, workflowFixtures()]); globalThis.fetch = api.fetch; @@ -350,43 +365,52 @@ describe("ActionsPage", () => { it("reloads a changed workflow definition once after conflict without replaying dispatch", async () => { let catalogReads = 0; const conflictRecovery: MockRouteOverride = (request) => { - if (request.method === "GET" - && request.url.pathname === "/api/v1/actions/github/acme/alpha/workflows") { + if (request.method === "GET" && request.url.pathname === "/api/v1/actions/github/acme/alpha/workflows") { catalogReads += 1; return jsonResponse({ repo: { ...repoSummary("alpha").repo, default_branch: "trunk" }, environments: [], - workflows: [{ - id: "alpha-deploy.yml", - name: "alpha deploy", - path: ".github/workflows/alpha-deploy.yml", - state: "active", - available: true, - definition_sha: catalogReads === 1 ? "alpha-definition" : "alpha-definition-2", - inputs: catalogReads === 1 - ? [] - : [{ - name: "channel", - type: "choice", - required: true, - has_default: true, - default: "stable", - options: ["stable", "beta"], - }], - web_url: "https://github.com/acme/alpha/actions/workflows/alpha-deploy.yml", - }], + workflows: [ + { + id: "alpha-deploy.yml", + name: "alpha deploy", + path: ".github/workflows/alpha-deploy.yml", + state: "active", + available: true, + definition_sha: catalogReads === 1 ? "alpha-definition" : "alpha-definition-2", + inputs: + catalogReads === 1 + ? [] + : [ + { + name: "channel", + type: "choice", + required: true, + has_default: true, + default: "stable", + options: ["stable", "beta"], + }, + ], + web_url: "https://github.com/acme/alpha/actions/workflows/alpha-deploy.yml", + }, + ], }); } - if (request.method === "POST" - && request.url.pathname.endsWith("/actions/github/acme/alpha/workflows/alpha-deploy.yml/dispatch")) { - return jsonResponse({ - code: "conflict", - detail: "Workflow definition changed.", - details: { reason: "workflow_definition_changed" }, - status: 409, - title: "Conflict", - type: "about:blank", - }, 409); + if ( + request.method === "POST" && + request.url.pathname.endsWith("/actions/github/acme/alpha/workflows/alpha-deploy.yml/dispatch") + ) { + return jsonResponse( + { + code: "conflict", + detail: "Workflow definition changed.", + details: { reason: "workflow_definition_changed" }, + status: 409, + title: "Conflict", + type: "about:blank", + }, + 409, + ); } return null; }; @@ -411,17 +435,22 @@ describe("ActionsPage", () => { ["validationError", 400], ] as const)("presents %s rejection as a fresh-cycle recovery without automatic POST", async (code, status) => { const rejection: MockRouteOverride = (request) => { - if (request.method !== "POST" - || !request.url.pathname.endsWith("/actions/github/acme/alpha/workflows/alpha-deploy.yml/dispatch")) { + if ( + request.method !== "POST" || + !request.url.pathname.endsWith("/actions/github/acme/alpha/workflows/alpha-deploy.yml/dispatch") + ) { return null; } - return jsonResponse({ - code, - detail: `Rejected with ${code}.`, + return jsonResponse( + { + code, + detail: `Rejected with ${code}.`, + status, + title: "Rejected", + type: "about:blank", + }, status, - title: "Rejected", - type: "about:blank", - }, status); + ); }; api = createMockApiFetch([rejection, workflowFixtures()]); globalThis.fetch = api.fetch; @@ -442,29 +471,34 @@ describe("ActionsPage", () => { it("dispatches the same workflow twice only after two deliberate confirmations", async () => { let dispatches = 0; const accepted: MockRouteOverride = (request) => { - if (request.method !== "POST" - || !request.url.pathname.endsWith("/actions/github/acme/alpha/workflows/alpha-deploy.yml/dispatch")) { + if ( + request.method !== "POST" || + !request.url.pathname.endsWith("/actions/github/acme/alpha/workflows/alpha-deploy.yml/dispatch") + ) { return null; } dispatches += 1; - return jsonResponse({ - accepted: true, - locating_run: false, - actor: "maintainer", - run: { + return jsonResponse( + { + accepted: true, + locating_run: false, actor: "maintainer", - conclusion: "success", - event: "workflow_dispatch", - head_sha: `head-${dispatches}`, - id: `repeat-run-${dispatches}`, - name: "alpha deploy", - ref: "trunk", - run_number: 10 + dispatches, - status: "completed", - web_url: `https://github.com/acme/alpha/actions/runs/${dispatches}`, - workflow_id: "alpha-deploy.yml", + run: { + actor: "maintainer", + conclusion: "success", + event: "workflow_dispatch", + head_sha: `head-${dispatches}`, + id: `repeat-run-${dispatches}`, + name: "alpha deploy", + ref: "trunk", + run_number: 10 + dispatches, + status: "completed", + web_url: `https://github.com/acme/alpha/actions/runs/${dispatches}`, + workflow_id: "alpha-deploy.yml", + }, }, - }, 202); + 202, + ); }; api = createMockApiFetch([accepted, workflowFixtures()]); globalThis.fetch = api.fetch; diff --git a/frontend/src/lib/components/actions/WorkflowDispatchDialog.test.ts b/frontend/src/lib/components/actions/WorkflowDispatchDialog.test.ts index 76bbcf9c95..1b199b63f9 100644 --- a/frontend/src/lib/components/actions/WorkflowDispatchDialog.test.ts +++ b/frontend/src/lib/components/actions/WorkflowDispatchDialog.test.ts @@ -10,7 +10,16 @@ vi.mock("../../app/runtime-context.js", () => ({ })); import WorkflowDispatchDialog from "./WorkflowDispatchDialog.svelte"; -const workflow = { available: true, definition_sha: "sha", id: "deploy", inputs: [], name: "Deploy", path: "deploy.yml", state: "active", web_url: "https://github.com/actions/deploy" } as const; +const workflow = { + available: true, + definition_sha: "sha", + id: "deploy", + inputs: [], + name: "Deploy", + path: "deploy.yml", + state: "active", + web_url: "https://github.com/actions/deploy", +} as const; const operation = { available: true } as const; it("restores trigger focus when canceled before admission", async () => { @@ -18,7 +27,19 @@ it("restores trigger focus when canceled before admission", async () => { document.body.append(trigger); trigger.focus(); const onclose = vi.fn(); - render(WorkflowDispatchDialog, { open: true, workflow, environments: [], initialRef: "main", operation, state: { kind: "idle" }, trigger, onsubmit: vi.fn(), onclose, onreload: vi.fn(), onnewcycle: vi.fn() }); + render(WorkflowDispatchDialog, { + open: true, + workflow, + environments: [], + initialRef: "main", + operation, + state: { kind: "idle" }, + trigger, + onsubmit: vi.fn(), + onclose, + onreload: vi.fn(), + onnewcycle: vi.fn(), + }); await fireEvent.click(screen.getByRole("button", { name: "Cancel" })); expect(onclose).toHaveBeenCalledOnce(); await waitFor(() => expect(document.activeElement).toBe(trigger)); @@ -30,7 +51,19 @@ it.each([ [{ kind: "conflict" } as const], ])("refuses Escape and overlay dismissal while state is %s", async (state) => { const onclose = vi.fn(); - render(WorkflowDispatchDialog, { open: true, workflow, environments: [], initialRef: "main", operation, state, trigger: null, onsubmit: vi.fn(), onclose, onreload: vi.fn(), onnewcycle: vi.fn() }); + render(WorkflowDispatchDialog, { + open: true, + workflow, + environments: [], + initialRef: "main", + operation, + state, + trigger: null, + onsubmit: vi.fn(), + onclose, + onreload: vi.fn(), + onnewcycle: vi.fn(), + }); const dialog = screen.getByRole("dialog"); await fireEvent.keyDown(window, { key: "Escape" }); await fireEvent.click(dialog.parentElement as HTMLElement); @@ -41,7 +74,18 @@ it.each([ it("reloads a conflict exactly once and closes only after acknowledgement", async () => { const onclose = vi.fn(); const onreload = vi.fn(); - const base = { open: true, workflow, environments: [], initialRef: "main", operation, trigger: null, onsubmit: vi.fn(), onclose, onreload, onnewcycle: vi.fn() }; + const base = { + open: true, + workflow, + environments: [], + initialRef: "main", + operation, + trigger: null, + onsubmit: vi.fn(), + onclose, + onreload, + onnewcycle: vi.fn(), + }; const view = render(WorkflowDispatchDialog, { ...base, state: { kind: "conflict" } as const }); const reload = screen.getByRole("button", { name: "Reload workflows" }); await Promise.all([fireEvent.click(reload), fireEvent.click(reload)]); @@ -55,7 +99,18 @@ it("reloads a conflict exactly once and closes only after acknowledgement", asyn it("allows one reload in each distinct conflict cycle", async () => { const onreload = vi.fn(); - const base = { open: true, workflow, environments: [], initialRef: "main", operation, trigger: null, onsubmit: vi.fn(), onclose: vi.fn(), onreload, onnewcycle: vi.fn() }; + const base = { + open: true, + workflow, + environments: [], + initialRef: "main", + operation, + trigger: null, + onsubmit: vi.fn(), + onclose: vi.fn(), + onreload, + onnewcycle: vi.fn(), + }; const view = render(WorkflowDispatchDialog, { ...base, state: { kind: "conflict" } as const }); await fireEvent.click(screen.getByRole("button", { name: "Reload workflows" })); expect(onreload).toHaveBeenCalledTimes(1); @@ -118,19 +173,21 @@ it("renders ambiguous candidates and makes Dispatch again only begin a fresh con state: { kind: "uncertain", message: "The provider could not confirm the dispatch.", - candidates: [{ - actor: "maintainer", - conclusion: "", - event: "workflow_dispatch", - head_sha: "candidate-head", - id: "candidate-9", - name: "Deploy", - ref: "main", - run_number: 9, - status: "queued", - web_url: "https://github.com/acme/app/actions/runs/9", - workflow_id: "deploy", - }], + candidates: [ + { + actor: "maintainer", + conclusion: "", + event: "workflow_dispatch", + head_sha: "candidate-head", + id: "candidate-9", + name: "Deploy", + ref: "main", + run_number: 9, + status: "queued", + web_url: "https://github.com/acme/app/actions/runs/9", + workflow_id: "deploy", + }, + ], }, trigger: null, onsubmit, diff --git a/frontend/src/lib/components/actions/WorkflowDispatchForm.svelte b/frontend/src/lib/components/actions/WorkflowDispatchForm.svelte index a5552142e2..ccb8aeb5dc 100644 --- a/frontend/src/lib/components/actions/WorkflowDispatchForm.svelte +++ b/frontend/src/lib/components/actions/WorkflowDispatchForm.svelte @@ -1,7 +1,7 @@ diff --git a/frontend/src/lib/components/actions/WorkflowRunList.test.ts b/frontend/src/lib/components/actions/WorkflowRunList.test.ts index ae8a21dc21..f8541d973c 100644 --- a/frontend/src/lib/components/actions/WorkflowRunList.test.ts +++ b/frontend/src/lib/components/actions/WorkflowRunList.test.ts @@ -53,6 +53,20 @@ it("exposes compact textual run data, local time, and secure provider links", () expect(screen.getByRole("link", { name: "Open on GitHub" }).getAttribute("target")).toBe("_blank"); }); +it.each([ + ["https://gitlab.com/acme/app/-/pipelines/2", "GitLab"], + ["https://forge.acme.test/acme/app/actions/runs/2", "forge.acme.test"], +])("labels provider links for %s", (webURL, providerLabel) => { + render(WorkflowRunList, { + runs: [{ ...runs[0]!, web_url: webURL }], + jobs: {}, + loadingJobs: [], + onexpand: vi.fn(), + oncollapse: vi.fn(), + }); + expect(screen.getByRole("link", { name: `Open on ${providerLabel}` })).toBeTruthy(); +}); + it("omits unsafe provider links", () => { render(WorkflowRunList, { runs: [{ ...runs[0]!, web_url: "javascript:alert(document.domain)" }], diff --git a/frontend/src/lib/components/actions/workflow-dispatch-presentation.test.ts b/frontend/src/lib/components/actions/workflow-dispatch-presentation.test.ts new file mode 100644 index 0000000000..f9f7ab6a2f --- /dev/null +++ b/frontend/src/lib/components/actions/workflow-dispatch-presentation.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it } from "vitest"; + +import type { + WorkflowActionsError, + WorkflowActionsSnapshot, + WorkflowDispatchState, +} from "../../stores/workflow-actions-workflow.js"; +import { workflowActionsErrorMessage, workflowDispatchPresentation } from "./workflow-dispatch-presentation.js"; + +const ref = { + provider: "github", + platformHost: "github.com", + owner: "acme", + name: "app", + repoPath: "acme/app", +} as const; + +const request = { + id: "dispatch-1", + ref, + workflowId: "deploy.yml", + expectedDefinitionSha: "definition-a", + dispatchRef: "main", + inputs: {}, + startedAt: 1, +} as const; + +const run = { + actor: "maintainer", + conclusion: "success", + event: "workflow_dispatch", + head_sha: "head-a", + id: "run-1", + name: "Deploy", + ref: "main", + run_number: 1, + status: "completed", + workflow_id: "deploy.yml", +} as const; + +const rejected = { + _tag: "ApiProblemError", + operation: "POST workflow dispatch", + problem: { + code: "validationError", + detail: "The ref is invalid.", + status: 400, + title: "Bad Request", + type: "about:blank", + }, +} as WorkflowActionsError; + +const conflict = { + _tag: "ApiProblemError", + operation: "POST workflow dispatch", + problem: { + code: "conflict", + detail: "Definition changed.", + details: { reason: "workflow_definition_changed" }, + status: 409, + title: "Conflict", + type: "about:blank", + }, +} as WorkflowActionsError; + +const reloadFailure = { + _tag: "TransientTransportError", + operation: "GET workflow catalog", + cause: new Error("Catalog transport failed."), +} as WorkflowActionsError; + +function snapshot(dispatch?: WorkflowDispatchState): WorkflowActionsSnapshot { + return { + ref, + catalog: null, + selectedWorkflow: null, + runs: [], + runsPage: { nextCursor: null, exhausted: true, loadingMore: false }, + jobs: {}, + loading: { catalog: false, runs: false, jobs: [] }, + dispatches: dispatch ? [dispatch] : [], + catalogRefreshErrors: {}, + error: null, + }; +} + +describe("workflow dispatch presentation", () => { + it("projects idle, pending, locating, and succeeded states", () => { + expect(workflowDispatchPresentation(null, "deploy.yml")).toEqual({ kind: "idle" }); + expect(workflowDispatchPresentation(snapshot({ kind: "pending", request }), "deploy.yml")).toEqual({ + kind: "pending", + }); + expect(workflowDispatchPresentation(snapshot({ kind: "locating", request }), "deploy.yml")).toEqual({ + kind: "locating", + }); + expect(workflowDispatchPresentation(snapshot({ kind: "succeeded", request }), "deploy.yml")).toEqual({ + kind: "succeeded", + }); + expect(workflowDispatchPresentation(snapshot({ kind: "succeeded", request, run }), "deploy.yml")).toEqual({ + kind: "succeeded", + run, + }); + }); + + it("projects failed, timeout, and uncertain recovery branches", () => { + expect(workflowDispatchPresentation(snapshot({ kind: "failed", request, error: rejected }), "deploy.yml")).toEqual({ + kind: "failed", + message: "The ref is invalid.", + }); + expect(workflowDispatchPresentation(snapshot({ kind: "locating_timed_out", request }), "deploy.yml")).toEqual({ + kind: "succeeded", + message: "The provider accepted the workflow, but its run was not observed.", + }); + expect( + workflowDispatchPresentation( + snapshot({ kind: "uncertain", request, error: rejected, candidates: [run] }), + "deploy.yml", + ), + ).toEqual({ + kind: "uncertain", + message: "The ref is invalid.", + candidates: [run], + }); + }); + + it("uses the shared reload fallback and cycle-specific conflict error", () => { + const current: WorkflowActionsSnapshot = { + ...snapshot({ kind: "failed", request, error: conflict }), + catalogRefreshErrors: { "deploy.yml": reloadFailure }, + }; + expect(workflowDispatchPresentation(current, "deploy.yml")).toEqual({ + kind: "conflict", + reloadError: "Catalog transport failed.", + }); + expect(workflowActionsErrorMessage(reloadFailure, "Could not reload workflows.")).toBe("Catalog transport failed."); + }); +}); diff --git a/frontend/src/lib/components/actions/workflow-dispatch-presentation.ts b/frontend/src/lib/components/actions/workflow-dispatch-presentation.ts new file mode 100644 index 0000000000..f13e99f98b --- /dev/null +++ b/frontend/src/lib/components/actions/workflow-dispatch-presentation.ts @@ -0,0 +1,71 @@ +import { ProblemCodes } from "../../api/problems.js"; +import { apiErrorMessage } from "../../api/runtime.js"; +import type { components } from "../../api/generated/schema.js"; +import type { WorkflowActionsError, WorkflowActionsSnapshot } from "../../stores/workflow-actions-workflow.js"; + +type WorkflowRun = components["schemas"]["WorkflowRunResponse"]; + +export type WorkflowDispatchPresentationState = + | { readonly kind: "idle" } + | { readonly kind: "pending" } + | { readonly kind: "locating" } + | { readonly kind: "succeeded"; readonly run?: WorkflowRun; readonly message?: string } + | { readonly kind: "failed"; readonly message: string } + | { + readonly kind: "uncertain"; + readonly message: string; + readonly candidates: readonly WorkflowRun[]; + } + | { readonly kind: "conflict"; readonly reloadError?: string }; + +const outcomeFallback = "The workflow outcome could not be confirmed."; +const reloadFallback = "Workflow data could not be refreshed."; + +export function workflowActionsErrorMessage(error: WorkflowActionsError, fallback: string): string { + if (error._tag === "ApiProblemError") { + return apiErrorMessage(error.problem, fallback); + } + if ("cause" in error && error.cause instanceof Error) return error.cause.message; + return fallback; +} + +export function workflowDispatchPresentation( + snapshot: WorkflowActionsSnapshot | null, + workflowId: string | null, +): WorkflowDispatchPresentationState { + if (!workflowId) return { kind: "idle" }; + const dispatch = [...(snapshot?.dispatches ?? [])] + .reverse() + .find((candidate) => candidate.request.workflowId === workflowId); + if (!dispatch) return { kind: "idle" }; + if (dispatch.kind === "pending") return { kind: "pending" }; + if (dispatch.kind === "locating") return { kind: "locating" }; + if (dispatch.kind === "succeeded") { + return dispatch.run === undefined ? { kind: "succeeded" } : { kind: "succeeded", run: dispatch.run }; + } + if ( + dispatch.kind === "failed" && + dispatch.error._tag === "ApiProblemError" && + dispatch.error.problem.code === ProblemCodes.conflict && + dispatch.error.problem.details?.["reason"] === "workflow_definition_changed" + ) { + const reloadError = snapshot?.catalogRefreshErrors[workflowId]; + return reloadError + ? { kind: "conflict", reloadError: workflowActionsErrorMessage(reloadError, reloadFallback) } + : { kind: "conflict" }; + } + if (dispatch.kind === "failed") { + return { kind: "failed", message: workflowActionsErrorMessage(dispatch.error, outcomeFallback) }; + } + if (dispatch.kind === "locating_timed_out") { + return { + kind: "succeeded", + message: "The provider accepted the workflow, but its run was not observed.", + }; + } + return { + kind: "uncertain", + message: workflowActionsErrorMessage(dispatch.error, outcomeFallback), + candidates: dispatch.candidates, + }; +} diff --git a/frontend/src/lib/components/detail/PullDetail.svelte b/frontend/src/lib/components/detail/PullDetail.svelte index 1e2f2c0761..48e81d019b 100644 --- a/frontend/src/lib/components/detail/PullDetail.svelte +++ b/frontend/src/lib/components/detail/PullDetail.svelte @@ -25,7 +25,7 @@ } from "../../api/types.js"; import type { DetailSyncMode } from "../../stores/detail.svelte.js"; import type { MutationCallbacks } from "../../stores/ordered-mutations.js"; - import { ProblemCodes, type ConflictReason } from "../../api/problems.js"; + import type { ConflictReason } from "../../api/problems.js"; import { showFlash } from "../../stores/flash.svelte.js"; import { getStores, getActions, @@ -33,10 +33,8 @@ } from "../../context.js"; import MarkdownHtml from "../shared/MarkdownHtml.svelte"; import WorkflowDispatchDialog from "../actions/WorkflowDispatchDialog.svelte"; - import type { - WorkflowDispatchPresentationState, - WorkflowDispatchRequest, - } from "../actions/WorkflowDispatchForm.svelte"; + import type { WorkflowDispatchRequest } from "../actions/WorkflowDispatchForm.svelte"; + import { workflowDispatchPresentation } from "../actions/workflow-dispatch-presentation.js"; import { buildPullRequestFilesRoute } from "../../routes.js"; import { moveTaskListItem, toggleTaskListItem } from "../../utils/task-list.js"; import type { ApplySuggestionRequest } from "../../utils/markdown-suggestions.js"; @@ -127,11 +125,7 @@ recordWorkspaceCreated, resolveControllerlessWorkspaceRef, } from "../../stores/workspace-create-pending.svelte.js"; - import type { - WorkflowActionsError, - WorkflowDefinition, - WorkflowDispatchState, - } from "../../stores/workflow-actions-workflow.js"; + import type { WorkflowDefinition } from "../../stores/workflow-actions-workflow.js"; type ChipTrailing = ComponentProps["trailing"]; @@ -1352,64 +1346,13 @@ ? detail.merge_request.HeadBranch : detail.merge_request.BaseBranch; }); - const workflowDialogPresentation = $derived.by( - (): WorkflowDispatchPresentationState => { - const workflow = workflowDialogWorkflow; - if (!workflow) return { kind: "idle" }; - const actionSnapshot = workflowActions.getSnapshot(routeRef); - const dispatch = [...(actionSnapshot?.dispatches ?? [])] - .reverse() - .find((candidate) => candidate.request.workflowId === workflow.id); - if (!dispatch) return { kind: "idle" }; - if (dispatch.kind === "pending") return { kind: "pending" }; - if (dispatch.kind === "locating") return { kind: "locating" }; - if (dispatch.kind === "succeeded") return dispatch.run === undefined ? { kind: "succeeded" } : { kind: "succeeded", run: dispatch.run }; - if ( - dispatch.kind === "failed" - && dispatch.error._tag === "ApiProblemError" - && dispatch.error.problem.code === ProblemCodes.conflict - && dispatch.error.problem.details?.["reason"] === "workflow_definition_changed" - ) { - const reloadError = actionSnapshot?.catalogRefreshErrors[workflow.id]; - return reloadError - ? { kind: "conflict", reloadError: workflowActionErrorMessage(reloadError, "Could not reload workflows.") } - : { kind: "conflict" }; - } - if (dispatch.kind === "failed") { - return { kind: "failed", message: workflowDispatchFailureMessage(dispatch) }; - } - if (dispatch.kind === "locating_timed_out") { - return { - kind: "succeeded", - message: "The provider accepted the workflow, but its run was not observed.", - }; - } - return { - kind: "uncertain", - message: workflowDispatchFailureMessage(dispatch), - candidates: dispatch.candidates, - }; - }, + const workflowDialogPresentation = $derived.by(() => + workflowDispatchPresentation( + workflowActions.getSnapshot(routeRef), + workflowDialogWorkflow?.id ?? null, + ) ); - function workflowActionErrorMessage(error: WorkflowActionsError, fallback: string): string { - if (error._tag === "ApiProblemError") { - return apiErrorMessage(error.problem, fallback); - } - if ("cause" in error && error.cause instanceof Error) return error.cause.message; - return fallback; - } - - function workflowDispatchFailureMessage(dispatch: WorkflowDispatchState): string { - if (dispatch.kind === "locating_timed_out") { - return "The provider accepted the workflow, but its run was not observed."; - } - if (dispatch.kind === "failed" || dispatch.kind === "uncertain") { - return workflowActionErrorMessage(dispatch.error, "The workflow outcome could not be confirmed."); - } - return "The workflow outcome could not be confirmed."; - } - function openWorkflowDialog(workflow: WorkflowDefinition): void { if (!workflowCatalogDemandEnabled || !workflow.available) return; workflowDialogWorkflow = workflow; diff --git a/frontend/src/lib/stores/workflow-actions-workflow.test.ts b/frontend/src/lib/stores/workflow-actions-workflow.test.ts index 8ed318eaa7..5c5978dd8e 100644 --- a/frontend/src/lib/stores/workflow-actions-workflow.test.ts +++ b/frontend/src/lib/stores/workflow-actions-workflow.test.ts @@ -354,6 +354,57 @@ it.effect("polls non-terminal runs after 5 seconds and terminal-only runs after ); }); +it.effect("does not treat a conclusion name in status as terminal", () => { + const probe = makeApiProbe({ + runs: () => ({ + repo: apiRepo(), + items: [run({ status: "success", conclusion: "" })], + exhausted: true, + }), + }); + return withWorkflow( + probe, + Effect.gen(function* () { + const workflow = yield* WorkflowActionsWorkflow; + yield* workflow.selectWorkflow(github, "deploy.yml"); + const owner = yield* workflow.watchRepository("surface", github, () => {}).pipe(Effect.forkChild); + yield* settle; + assert.strictEqual(probe.calls.runs, 1); + yield* TestClock.adjust("5 seconds"); + yield* settle; + assert.strictEqual(probe.calls.runs, 2); + yield* Fiber.interrupt(owner); + }), + ); +}); + +it.effect("treats a populated conclusion as terminal even before status convergence", () => { + const probe = makeApiProbe({ + runs: () => ({ + repo: apiRepo(), + items: [run({ status: "in_progress", conclusion: "failure" })], + exhausted: true, + }), + }); + return withWorkflow( + probe, + Effect.gen(function* () { + const workflow = yield* WorkflowActionsWorkflow; + yield* workflow.selectWorkflow(github, "deploy.yml"); + const owner = yield* workflow.watchRepository("surface", github, () => {}).pipe(Effect.forkChild); + yield* settle; + assert.strictEqual(probe.calls.runs, 1); + yield* TestClock.adjust("5 seconds"); + yield* settle; + assert.strictEqual(probe.calls.runs, 1); + yield* TestClock.adjust("25 seconds"); + yield* settle; + assert.strictEqual(probe.calls.runs, 2); + yield* Fiber.interrupt(owner); + }), + ); +}); + it.effect("wakes an idle selected-workflow poll when dispatch is accepted", () => { const probe = makeApiProbe({ dispatch: () => ({ diff --git a/frontend/src/lib/stores/workflow-actions-workflow.ts b/frontend/src/lib/stores/workflow-actions-workflow.ts index a2a0e6f2d9..ffc5d59c39 100644 --- a/frontend/src/lib/stores/workflow-actions-workflow.ts +++ b/frontend/src/lib/stores/workflow-actions-workflow.ts @@ -195,8 +195,7 @@ function emptySnapshot(ref: ProviderRouteRef): WorkflowActionsSnapshot { } function isTerminalRun(run: WorkflowRun): boolean { - const status = run.status.toLowerCase(); - return status === "completed" || status === "cancelled" || status === "failure" || status === "success"; + return run.status.toLowerCase() === "completed" || run.conclusion.trim() !== ""; } function dispatchNeedsPolling(state: WorkflowDispatchState, now: number): boolean { diff --git a/frontend/tests/e2e-full/actions.spec.ts b/frontend/tests/e2e-full/actions.spec.ts index 29e118d59f..c9c5e60ce9 100644 --- a/frontend/tests/e2e-full/actions.spec.ts +++ b/frontend/tests/e2e-full/actions.spec.ts @@ -119,14 +119,6 @@ test("runs typed Actions workflows and applies pull request ref defaults until t await expect(sameRepoDialog.getByRole("textbox", { name: "Git ref" })).toHaveValue("feature/caching"); await sameRepoDialog.getByRole("button", { name: "Cancel" }).click(); - const forkDialog = await openReleaseFromPull(page, 2); - await expect(forkDialog.getByRole("textbox", { name: "Git ref" })).toHaveValue("main"); - await forkDialog.getByRole("button", { name: "Cancel" }).click(); - - const mergedDialog = await openReleaseFromPull(page, 3); - await expect(mergedDialog.getByRole("textbox", { name: "Git ref" })).toHaveValue("main"); - await mergedDialog.getByRole("button", { name: "Cancel" }).click(); - await setActionsMode(page, false); await expect(page.getByRole("button", { name: "Actions", exact: true })).toHaveCount(0); const readsAfterDisable = workflowReads.length; diff --git a/internal/github/sync.go b/internal/github/sync.go index c9c380fddd..43c886ac64 100644 --- a/internal/github/sync.go +++ b/internal/github/sync.go @@ -2240,8 +2240,18 @@ func workflowDefinitionReadMustAbort(err error) bool { if _, ok := errors.AsType[*gh.RateLimitError](err); ok { return true } - _, abuseLimited := errors.AsType[*gh.AbuseRateLimitError](err) - return abuseLimited + if _, ok := errors.AsType[*gh.AbuseRateLimitError](err); ok { + return true + } + status := githubStatusCode(err) + if status == http.StatusNotFound { + return false + } + if status == http.StatusUnauthorized || status >= http.StatusInternalServerError { + return true + } + _, transportFailure := errors.AsType[*url.Error](err) + return transportFailure } func (p *gitHubClientProvider) ListManualWorkflows( diff --git a/internal/github/workflow_provider_test.go b/internal/github/workflow_provider_test.go index 4d481b2b0c..1f6490e94d 100644 --- a/internal/github/workflow_provider_test.go +++ b/internal/github/workflow_provider_test.go @@ -5,6 +5,7 @@ import ( "errors" "net/http" "net/http/httptest" + "net/url" "testing" "time" @@ -199,6 +200,25 @@ func TestGitHubWorkflowProviderAbortsCatalogOnFatalDefinitionErrors(t *testing.T StatusCode: http.StatusForbidden, Request: httptest.NewRequest(http.MethodGet, "https://api.github.com/repos/acme/widgets/contents/workflow.yml", nil), }}}, + {name: "unauthorized", err: &gh.ErrorResponse{ + Response: &http.Response{ + StatusCode: http.StatusUnauthorized, + Request: httptest.NewRequest(http.MethodGet, "https://api.github.com/repos/acme/widgets/contents/workflow.yml", nil), + }, + Message: "bad credentials", + }}, + {name: "server failure", err: &gh.ErrorResponse{ + Response: &http.Response{ + StatusCode: http.StatusServiceUnavailable, + Request: httptest.NewRequest(http.MethodGet, "https://api.github.com/repos/acme/widgets/contents/workflow.yml", nil), + }, + Message: "service unavailable", + }}, + {name: "transport", err: &url.Error{ + Op: "Get", + URL: "https://api.github.com/repos/acme/widgets/contents/workflow.yml", + Err: errors.New("connection reset"), + }}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { @@ -398,3 +418,30 @@ func TestRoutedClientRoutesWorkflowOperationsByRepository(t *testing.T) { }, exact.calls) assert.Empty(fallback.calls) } + +func TestRoutedClientWorkflowMethodsRejectClientsWithoutOptionalInterfaces(t *testing.T) { + require := require.New(t) + router, err := NewHostRouter( + "github.com", + &Route{Key: RouteKey{Host: "github.com"}, Client: &mockClient{}}, + &Route{Key: RouteKey{Host: "github.com", Owner: "acme", Name: "widgets"}, Client: &mockClient{}}, + ) + require.NoError(err) + routed, err := NewRoutedClient(router) + require.NoError(err) + + _, err = routed.ListRepositoryWorkflows(t.Context(), "acme", "widgets") + require.ErrorIs(err, platform.ErrUnsupportedCapability) + _, _, err = routed.GetWorkflowDefinition(t.Context(), "acme", "widgets", "release.yml", "main") + require.ErrorIs(err, platform.ErrUnsupportedCapability) + _, err = routed.ListRepositoryEnvironments(t.Context(), "acme", "widgets") + require.ErrorIs(err, platform.ErrUnsupportedCapability) + _, err = routed.ListManualWorkflowRuns(t.Context(), "acme", "widgets", 42, platform.WorkflowRunQuery{}) + require.ErrorIs(err, platform.ErrUnsupportedCapability) + _, err = routed.ListManualWorkflowJobs(t.Context(), "acme", "widgets", 99) + require.ErrorIs(err, platform.ErrUnsupportedCapability) + _, err = routed.DispatchManualWorkflow( + t.Context(), "acme", "widgets", 42, gh.CreateWorkflowDispatchEventRequest{Ref: "main"}, + ) + require.ErrorIs(err, platform.ErrUnsupportedCapability) +} diff --git a/internal/server/server.go b/internal/server/server.go index 3b8a9a2dbb..d60cd382b8 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -1052,7 +1052,6 @@ func newServer( Resolver: repoResolver, Syncer: syncer, RepoOperations: s.repoOperations, - Now: s.now, }) s.pullAPI = pullapi.New(pullapi.Deps{ DB: database, diff --git a/internal/server/workflowapi/handler.go b/internal/server/workflowapi/handler.go index 309b77b037..b9b5df23f6 100644 --- a/internal/server/workflowapi/handler.go +++ b/internal/server/workflowapi/handler.go @@ -2,8 +2,6 @@ package workflowapi import ( - "time" - "go.kenn.io/forge/internal/db" ghclient "go.kenn.io/forge/internal/github" "go.kenn.io/forge/internal/server/httpapi" @@ -24,26 +22,19 @@ type Deps struct { Resolver *httpapi.RepositoryResolver Syncer *ghclient.Syncer RepoOperations func(db.Repo) httpapi.RepoOperations - Now func() time.Time } type Handler struct { resolver *httpapi.RepositoryResolver syncer *ghclient.Syncer repoOperations func(db.Repo) httpapi.RepoOperations - now func() time.Time } func New(deps Deps) *Handler { - now := deps.Now - if now == nil { - now = time.Now - } return &Handler{ resolver: deps.Resolver, syncer: deps.Syncer, repoOperations: deps.RepoOperations, - now: now, } } diff --git a/internal/server/workflowapi/routes_test.go b/internal/server/workflowapi/routes_test.go index dbb8ac0ec9..6fbd98d369 100644 --- a/internal/server/workflowapi/routes_test.go +++ b/internal/server/workflowapi/routes_test.go @@ -94,7 +94,6 @@ func workflowFixture(t *testing.T, provider *workflowTestProvider, operation htt Resolver: resolver, Syncer: syncer, RepoOperations: func(db.Repo) httpapi.RepoOperations { return httpapi.RepoOperations{DispatchWorkflow: operation} }, - Now: func() time.Time { return time.Date(2026, 8, 27, 12, 0, 0, 0, time.UTC) }, }) mux := http.NewServeMux() config := huma.DefaultConfig("workflow test", "0") From 9f4df8e1080b616739753fd8e351390d94c973d5 Mon Sep 17 00:00:00 2001 From: Max Mill Date: Tue, 1 Sep 2026 18:06:04 +0200 Subject: [PATCH 38/41] fix: separate workflow dispatch from pull request actions Keep pull request lifecycle decisions in the primary action row and group workflow dispatch with workspace utilities. Preserve a single measured Actions overflow for constrained widths while anchoring the wide workflow menu directly to its trigger. --- context/ui-design-system.md | 3 + ...lDetail.workflow-actions.browser.svelte.ts | 50 +++++-- .../lib/components/detail/PullDetail.svelte | 134 ++++++++++-------- .../lib/components/detail/PullDetail.test.ts | 49 +++---- 4 files changed, 133 insertions(+), 103 deletions(-) diff --git a/context/ui-design-system.md b/context/ui-design-system.md index 79e5506838..909ec63f81 100644 --- a/context/ui-design-system.md +++ b/context/ui-design-system.md @@ -122,6 +122,9 @@ prebundled: keep it in vite `optimizeDeps.exclude` with transitive deps as wrapped instead. Every stage must expose the same accessible names (`frontend/src/lib/components/roborev/ReviewDrawer.svelte::.footer-actions-fit`, `frontend/src/lib/components/detail/PullDetail.svelte::measuredPrimaryActions`). +- Pull-request lifecycle decisions stay in the primary action row; workspace + creation and workflow dispatch form a utility row, joining the measured + `Actions` overflow only under pressure (`frontend/src/lib/components/detail/PullDetail.svelte::workflowActionsMenu`). - Flash: one shared store (`frontend/src/lib/stores/flash.svelte.ts`); kit `FlashBanner` mounts once per shell in a page-level fixed layer below measured shell chrome and above modal backdrops, never inside feature containers; headerless shells diff --git a/frontend/src/PullDetail.workflow-actions.browser.svelte.ts b/frontend/src/PullDetail.workflow-actions.browser.svelte.ts index e91941681c..02e20b056e 100644 --- a/frontend/src/PullDetail.workflow-actions.browser.svelte.ts +++ b/frontend/src/PullDetail.workflow-actions.browser.svelte.ts @@ -37,13 +37,13 @@ function pullDetail(): PullDetail { read_workflows: true, read_workflow_runs: true, workflow_dispatch: true, - read_labels: false, + read_labels: true, read_markdown_images: true, read_authenticated_user: true, comment_mutation: false, state_mutation: true, merge_mutation: true, - label_mutation: false, + label_mutation: true, assignee_mutation: false, reviewer_mutation: false, review_mutation: true, @@ -64,7 +64,7 @@ function pullDetail(): PullDetail { const unavailable = { available: false } as const; const operations: NonNullable = { add_comment: unavailable, - add_label: unavailable, + add_label: { available: true }, apply_review_suggestion: unavailable, approve_workflow: unavailable, close_issue: unavailable, @@ -76,7 +76,7 @@ function pullDetail(): PullDetail { mark_draft: unavailable, mark_ready_for_review: unavailable, merge_pr: { available: true }, - remove_label: unavailable, + remove_label: { available: true }, reopen_issue: unavailable, reopen_pr: unavailable, reply_review_thread: unavailable, @@ -185,7 +185,7 @@ afterEach(async () => { }); describe("PullDetail provider workflow action geometry", () => { - it("uses one Actions trigger while primary pull actions move into that menu only under pressure", async () => { + it("places workflow dispatch beside workspace tools while primary actions collapse only under pressure", async () => { const detail = pullDetail(); const apiClient = { GET: vi.fn(async () => ({ @@ -271,7 +271,7 @@ describe("PullDetail provider workflow action geometry", () => { platformHost: "github.com", repoPath: "acme/widgets", hideTabs: true, - hideWorkspaceAction: true, + hideWorkspaceAction: false, autoSync: false, }, }, @@ -293,40 +293,60 @@ describe("PullDetail provider workflow action geometry", () => { ]), }); + let workflowTrigger: HTMLButtonElement | null = null; + let workspaceTrigger: HTMLButtonElement | null = null; await vi.waitFor(() => { expect(visibleActionsTriggers()).toHaveLength(1); + workflowTrigger = visibleButton("Run workflow"); + workspaceTrigger = visibleButton("Create Workspace"); + expect(workflowTrigger).not.toBeNull(); + expect(workspaceTrigger).not.toBeNull(); + expect(workflowTrigger!.closest(".actions-row--utility")).toBe( + workspaceTrigger!.closest(".actions-row--utility"), + ); + expect(visibleButton("Actions")).toBeNull(); expect(visibleButton("Approve")).not.toBeNull(); expect(visibleButton("Squash and merge")).not.toBeNull(); expect(visibleButton("Close")).not.toBeNull(); }, WAIT); - visibleActionsTriggers()[0]!.click(); + workflowTrigger!.click(); await vi.waitFor(() => { - const workflowMenu = document.querySelector(".workflow-actions-menu"); + const workflowMenu = document.querySelector(".workflow-actions-menu--floating"); expect(workflowMenu).not.toBeNull(); expect(workflowMenu!.textContent).toContain("GitHub Actions"); expect(visibleButton("Release")).not.toBeNull(); - expect(visibleButton("Approve")).not.toBeNull(); - expect(visibleButton("Squash and merge")).not.toBeNull(); - expect(visibleButton("Close")).not.toBeNull(); + const triggerRect = workflowTrigger!.getBoundingClientRect(); + const menuRect = workflowMenu!.getBoundingClientRect(); + expect(Math.abs(menuRect.left - triggerRect.left)).toBeLessThanOrEqual(1); + expect(menuRect.top).toBeGreaterThanOrEqual(triggerRect.bottom); + expect(menuRect.width).toBeGreaterThanOrEqual(220); }, WAIT); - visibleActionsTriggers()[0]!.click(); + workflowTrigger!.click(); wrapper.style.width = "180px"; await vi.waitFor(() => { expect(document.querySelector(".pull-detail-content--actions-menu")).not.toBeNull(); expect(visibleActionsTriggers()).toHaveLength(1); + expect(visibleButton("Actions")).not.toBeNull(); + expect(visibleButton("Run workflow")).toBeNull(); + expect(visibleButton("Create Workspace")).toBeNull(); expect(visibleButton("Approve")).toBeNull(); expect(visibleButton("Squash and merge")).toBeNull(); expect(visibleButton("Close")).toBeNull(); }, WAIT); - visibleActionsTriggers()[0]!.click(); + visibleButton("Actions")!.click(); await vi.waitFor(() => { const menu = document.querySelector(".actions-menu-popover"); expect(menu).not.toBeNull(); - const labels = Array.from(menu!.querySelectorAll("button"), (button) => button.textContent?.trim()); - expect(labels).toEqual(expect.arrayContaining(["Approve", "Squash and merge", "Close", "Release"])); + const labels = Array.from( + menu!.querySelectorAll("button"), + (button) => button.getAttribute("aria-label") ?? button.textContent?.trim(), + ); + expect(labels).toEqual( + expect.arrayContaining(["Approve", "Squash and merge", "Close", "Labels", "Create Workspace", "Release"]), + ); expect(menu!.textContent).toContain("GitHub Actions"); expect(visibleActionsTriggers()).toHaveLength(1); }, WAIT); diff --git a/frontend/src/lib/components/detail/PullDetail.svelte b/frontend/src/lib/components/detail/PullDetail.svelte index 48e81d019b..887d0c218c 100644 --- a/frontend/src/lib/components/detail/PullDetail.svelte +++ b/frontend/src/lib/components/detail/PullDetail.svelte @@ -2712,6 +2712,31 @@ /> {/if} {/snippet} + {#snippet workflowActionsMenu(floating: boolean)} +
+
{workflowProviderLabel}
+ {#each workflowCatalog as workflow (workflow.id)} + + {/each} +
+ {/snippet} {#snippet measuredPrimaryActions(compactLabels = false)} {/snippet} @@ -2845,7 +2864,6 @@ class={[ "actions-menu-wrap", { - "actions-menu-wrap--workflows": hasWorkflowActions, "actions-menu-wrap--menu": hasPrimaryPRActions && primaryActionStage === 2, }, ]} @@ -2855,9 +2873,7 @@ class={[ "primary-actions-live", { - "actions-menu-popover": - (hasPrimaryPRActions && primaryActionStage === 2) - || (actionMenuOpen && !hasWorkflowActions), + "actions-menu-popover": hasPrimaryPRActions && primaryActionStage === 2, "primary-actions-live--open": hasPrimaryPRActions && primaryActionStage === 2 && actionMenuOpen, }, @@ -2874,50 +2890,52 @@ {@render labelActionButton(14)} {/if} - {#if !hideWorkspaceAction} -
+ {/if} + {#if !hideWorkspaceAction + || (hasWorkflowActions && (!hasPrimaryPRActions || primaryActionStage !== 2))} +
+ {#if !hideWorkspaceAction} {@render workspaceActionButton(primaryActionStage === 1)} -
- {/if} + {/if} + {#if hasWorkflowActions && (!hasPrimaryPRActions || primaryActionStage !== 2)} +
+ + {#if actionMenuOpen} + {@render workflowActionsMenu(true)} + {/if} +
+ {/if} +
{/if} - {#if actionMenuOpen && hasWorkflowActions} -
-
{workflowProviderLabel}
- {#each workflowCatalog as workflow (workflow.id)} - - {/each} -
+ {#if actionMenuOpen && hasWorkflowActions + && hasPrimaryPRActions && primaryActionStage === 2} + {@render workflowActionsMenu(false)} {/if} - + {#if hasPrimaryPRActions && primaryActionStage === 2} + + {/if} {#if hasPrimaryPRActions} {#if stateConflict === "stale_state"} @@ -3742,12 +3760,10 @@ display: contents; } - .actions-menu-wrap--workflows { - display: flex; + + .workflow-actions-control { position: relative; - z-index: 65; - align-items: flex-start; - gap: var(--space-4); + min-width: 0; } .actions-menu-wrap--menu { @@ -3771,7 +3787,7 @@ cursor: pointer; } - .actions-menu-wrap--workflows > .actions-menu-trigger { + .workflow-actions-control > .actions-menu-trigger { display: inline-flex; } @@ -3825,9 +3841,9 @@ } .workflow-actions-menu--floating { - right: 0; - left: auto; - width: max-content; + right: auto; + left: 0; + width: min(240px, calc(100cqw - 48px)); padding-top: var(--space-4); } diff --git a/frontend/src/lib/components/detail/PullDetail.test.ts b/frontend/src/lib/components/detail/PullDetail.test.ts index 32577ee0d8..a112737444 100644 --- a/frontend/src/lib/components/detail/PullDetail.test.ts +++ b/frontend/src/lib/components/detail/PullDetail.test.ts @@ -639,7 +639,7 @@ async function openReleaseWorkflow(detail: PullDetail, options: Parameters { expect(api.GET.mock.calls.some(([path]) => String(path).endsWith("/workflows"))).toBe(true); }); - await fireEvent.click(screen.getByRole("button", { name: "Actions" })); + await fireEvent.click(screen.getByRole("button", { name: "Run workflow" })); const release = await screen.findByRole("button", { name: "Release" }); await fireEvent.click(release); return { ...rendered, api }; @@ -726,7 +726,7 @@ describe("PullDetail provider workflow actions", () => { }), ); }); - await fireEvent.click(screen.getByRole("button", { name: "Actions" })); + await fireEvent.click(screen.getByRole("button", { name: "Run workflow" })); expect(await screen.findByText("GitLab Actions")).toBeTruthy(); expect(screen.getByRole("button", { name: "Release" })).toBeTruthy(); @@ -782,7 +782,7 @@ describe("PullDetail provider workflow actions", () => { expect(api.GET.mock.calls.some(([path]) => String(path).endsWith("/workflows"))).toBe(true); }); - expect(screen.getByRole("button", { name: "Actions" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "Run workflow" })).toBeTruthy(); expect(screen.queryByRole("button", { name: "Approve" })).toBeNull(); expect(screen.queryByRole("button", { name: /merge/i })).toBeNull(); expect(screen.queryByRole("button", { name: "Close" })).toBeNull(); @@ -803,7 +803,11 @@ describe("PullDetail provider workflow actions", () => { response: new Response(null, { status: 409 }), }); - await fireEvent.click(screen.getByRole("button", { name: "Run workflow" })); + await fireEvent.click( + within(screen.getByRole("dialog", { name: "Run workflow" })).getByRole("button", { + name: "Run workflow", + }), + ); const reload = await screen.findByRole("button", { name: "Reload workflows" }); await fireEvent.click(reload); @@ -1441,7 +1445,7 @@ describe("PullDetail approvals", () => { expect(screen.queryByText("old-route")).toBeNull(); }); - it("closes the label picker when the actions menu Labels action is clicked after reopening the menu", async () => { + it("toggles the label picker from the visible Labels action", async () => { const detail = pullDetail(); detail.repo.capabilities = { ...capabilities, @@ -1451,28 +1455,21 @@ describe("PullDetail approvals", () => { renderPullDetail(detail); - const actionsTrigger = screen.getByRole("button", { - name: "Actions", + const labelsTrigger = screen.getByRole("button", { + name: "Labels", }); - await fireEvent.click(actionsTrigger); - await fireEvent.click(getActionMenuLabelsButton()); + await fireEvent.click(labelsTrigger); expect(await screen.findByRole("dialog", { name: "Edit labels" })).toBeTruthy(); expect(document.querySelector(".actions-menu-popover")).toBeNull(); - await fireEvent.mouseDown(actionsTrigger); - await fireEvent.click(actionsTrigger); - expect(document.querySelector(".actions-menu-popover")).not.toBeNull(); - - const labelsAction = getActionMenuLabelsButton(); - await fireEvent.mouseDown(labelsAction); - await fireEvent.click(labelsAction); + await fireEvent.mouseDown(labelsTrigger); + await fireEvent.click(labelsTrigger); expect(screen.queryByRole("dialog", { name: "Edit labels" })).toBeNull(); - expect(document.querySelector(".actions-menu-popover")).toBeNull(); }); - it("opens the actions-menu label picker as a non-modal popover", async () => { + it("opens the visible Labels action as a non-modal popover", async () => { const detail = pullDetail(); detail.repo.capabilities = { ...capabilities, @@ -1482,8 +1479,7 @@ describe("PullDetail approvals", () => { renderPullDetail(detail); - await fireEvent.click(screen.getByRole("button", { name: "Actions" })); - await fireEvent.click(getActionMenuLabelsButton()); + await fireEvent.click(screen.getByRole("button", { name: "Labels" })); expect(await screen.findByRole("dialog", { name: "Edit labels" })).toBeTruthy(); expect(document.querySelector(".label-editor-backdrop")).toBeNull(); @@ -1492,7 +1488,7 @@ describe("PullDetail approvals", () => { expect(screen.queryByRole("dialog", { name: "Edit labels" })).toBeNull(); }); - it("keeps the actions menu Labels button on the compact action geometry", async () => { + it("keeps the visible Labels action on small button geometry", async () => { const detail = pullDetail(); detail.repo.capabilities = { ...capabilities, @@ -1502,17 +1498,12 @@ describe("PullDetail approvals", () => { renderPullDetail(detail); - await fireEvent.click(screen.getByRole("button", { name: "Actions" })); - - const labelsAction = getActionMenuLabelsButton(); + const labelsAction = screen.getByRole("button", { name: "Labels" }); const labelsIcon = labelsAction.querySelector("svg"); - const labelsItem = labelsAction.closest(".actions-menu-popover__item--labels"); expect(labelsAction.classList.contains("kit-button--sm")).toBe(true); - expect(labelsAction.parentElement).toBe(labelsItem); - expect(labelsItem?.classList.contains("label-editor-anchor")).toBe(true); - expect(labelsIcon?.getAttribute("width")).toBe("14"); - expect(labelsIcon?.getAttribute("height")).toBe("14"); + expect(labelsIcon?.getAttribute("width")).toBe("16"); + expect(labelsIcon?.getAttribute("height")).toBe("16"); }); it("uses the shared View menu to persist compact activity rows", async () => { From 9c21349814c6cbd125be6c01b9ae038657f06b17 Mon Sep 17 00:00:00 2001 From: Marius van Niekerk Date: Thu, 3 Sep 2026 13:14:46 -0400 Subject: [PATCH 39/41] fix: compile server tests against the shared JSON request helper CI could not build the internal/server test package for the workflow Actions branch, which failed the Go, race, and lint jobs at once. Main had moved the package-local doJSON test helper into the shared testutil package in the same window that the branch added two new callers, and the merge kept both without a compile error until they met in CI. This merges current main and points the two workflow Actions call sites at the shared helper. Local Go, lint, and guardrail checks now pass; the remaining local-only failures are tmux and pty timing tests that pass when run without competing load. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01KuhHYTTk1QzpastWi4zD7T --- internal/server/api_test.go | 2 +- internal/server/settings_test.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/server/api_test.go b/internal/server/api_test.go index c114839759..47213fc441 100644 --- a/internal/server/api_test.go +++ b/internal/server/api_test.go @@ -31201,7 +31201,7 @@ func TestAPIHeadRepoKindClassifiesSameRepoForkAndUnknown(t *testing.T) { CreatedAt: now, UpdatedAt: now, LastActivityAt: now, }) require.NoError(err) - response := doJSON( + response := testutil.DoJSON( t, srv, http.MethodGet, "/api/v1/pulls/gh/acme/widget/"+strconv.Itoa(number), nil, diff --git a/internal/server/settings_test.go b/internal/server/settings_test.go index 0bbd591c8b..2cae4702e6 100644 --- a/internal/server/settings_test.go +++ b/internal/server/settings_test.go @@ -699,7 +699,7 @@ func TestHandleUpdateSettingsPersistsModes(t *testing.T) { activity := srv.cfg.Activity activity.TimeRange = "30d" - rr = doJSON(t, srv, http.MethodPut, "/api/v1/settings", updateSettingsRequest{ + rr = testutil.DoJSON(t, srv, http.MethodPut, "/api/v1/settings", updateSettingsRequest{ Activity: &activity, }) require.Equal(http.StatusOK, rr.Code, rr.Body.String()) From 1fa52825fed06dab0ac1803a54d141c119140893 Mon Sep 17 00:00:00 2001 From: Marius van Niekerk Date: Thu, 3 Sep 2026 15:51:41 -0400 Subject: [PATCH 40/41] refactor: parse workflow files with actionlint instead of a custom walker The contributed branch shipped a 400-line hand-written walker over the YAML node tree to find workflow_dispatch inputs. The maintainer does not want to own a GitHub Actions grammar in this repository, so this replaces it with actionlint, which already models the workflow file including typed dispatch inputs, choice options, defaults, and required flags. Forge keeps only the projection onto the provider-neutral definition and the conversion of string defaults into their declared type. actionlint validates the whole file, so fixtures now carry a jobs section like any real workflow, and cases the old walker rejected but GitHub accepts (a boolean-looking string default, a numeric description) are dropped. go.mod pins actionlint to the fork commit behind rhysd/actionlint#730. The released v1.7.12 compiles against yaml/v4 rc.3 and fails to build with the rc.6 already required here; that PR is the upstream fix and the replace directive should be removed once it lands in a release. Co-Authored-By: Claude Fable 5.1 --- cmd/e2e-server/main.go | 15 + context/provider-architecture.md | 4 + go.mod | 7 +- go.sum | 10 +- internal/github/workflow_provider_test.go | 10 +- .../platform/github/workflow_definition.go | 421 ++++-------------- .../github/workflow_definition_test.go | 70 ++- 7 files changed, 167 insertions(+), 370 deletions(-) diff --git a/cmd/e2e-server/main.go b/cmd/e2e-server/main.go index 875a5c17cb..8ba9c4d6db 100644 --- a/cmd/e2e-server/main.go +++ b/cmd/e2e-server/main.go @@ -188,11 +188,21 @@ on: target: required: true type: environment +jobs: + noop: + runs-on: ubuntu-latest + steps: + - run: echo ok ` const e2eMaintenanceWorkflowDefinition = `name: Maintenance on: workflow_dispatch: +jobs: + noop: + runs-on: ubuntu-latest + steps: + - run: echo ok ` const e2ePushWorkflowDefinition = `name: Push checks @@ -200,6 +210,11 @@ on: push: branches: - main +jobs: + noop: + runs-on: ubuntu-latest + steps: + - run: echo ok ` type e2eWorkflowDispatch struct { diff --git a/context/provider-architecture.md b/context/provider-architecture.md index 949c584d33..6a86921768 100644 --- a/context/provider-architecture.md +++ b/context/provider-architecture.md @@ -84,6 +84,10 @@ Rules: - Workflow catalog partial availability is definition-specific: missing or undecodable files become unavailable rows, while cancellation, auth, server, transport, and rate failures abort the catalog (`internal/github/sync.go::workflowDefinitionReadMustAbort`). +- actionlint owns the GitHub workflow file grammar; Forge only projects its dispatch inputs + and types defaults. Do not hand-parse workflow YAML. The module is pinned to the fork + behind rhysd/actionlint#730 until a release compiles against yaml/v4 rc.6 + (`internal/platform/github/workflow_definition.go::ParseManualWorkflow`, `go.mod`). - Provider-backed comment deletes remove synchronized local rows only after upstream synchronization observes provider absence. DELETE itself changes no SQLite comment state; the UI hides a confirmed deletion while ordinary sync converges. Authoritative diff --git a/go.mod b/go.mod index 48c6bbc41c..0d64e02dd2 100644 --- a/go.mod +++ b/go.mod @@ -27,6 +27,7 @@ require ( github.com/modelcontextprotocol/go-sdk v1.7.0 github.com/oapi-codegen/runtime v1.6.0 github.com/posthog/posthog-go v1.23.1 + github.com/rhysd/actionlint v1.7.12 github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 github.com/shurcooL/githubv4 v0.0.0-20260209031235-2402fdf4a9ed github.com/sourcegraph/go-diff v0.8.0 @@ -65,6 +66,7 @@ require ( github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/bitfield/gotestdox v0.2.2 // indirect + github.com/bmatcuk/doublestar/v4 v4.10.0 // indirect github.com/buger/goterm v1.0.4 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect @@ -102,7 +104,7 @@ require ( github.com/ebitengine/purego v0.10.1 // indirect github.com/eiannone/keyboard v0.0.0-20220611211555-0d226195f203 // indirect github.com/emirpasic/gods v1.18.1 // indirect - github.com/fatih/color v1.18.0 // indirect + github.com/fatih/color v1.19.0 // indirect github.com/felixge/httpsnoop v1.1.0 // indirect github.com/fsnotify/fsevents v0.2.0 // indirect github.com/fvbommel/sortorder v1.1.0 // indirect @@ -199,6 +201,7 @@ require ( github.com/prometheus/otlptranslator v1.0.0 // indirect github.com/prometheus/procfs v0.20.1 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/robfig/cron/v3 v3.0.1 // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 // indirect github.com/secure-systems-lab/go-securesystemslib v0.11.0 // indirect @@ -287,3 +290,5 @@ tool ( github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen gotest.tools/gotestsum ) + +replace github.com/rhysd/actionlint => github.com/paulojmdias/actionlint v0.0.0-20260825163745-7f40b72a378c diff --git a/go.sum b/go.sum index ef58c2e310..90233cbca5 100644 --- a/go.sum +++ b/go.sum @@ -47,6 +47,8 @@ github.com/bitfield/gotestdox v0.2.2/go.mod h1:D+gwtS0urjBrzguAkTM2wodsTQYFHdpx8 github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ= github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= +github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs= +github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/buger/goterm v1.0.4 h1:Z9YvGmOih81P0FbVtEYTFF6YsSgxSUKEhf/f9bTMXbY= github.com/buger/goterm v1.0.4/go.mod h1:HiFWV3xnkolgrBV3mY8m0X0Pumt4zg4QhbdOzQtB8tE= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= @@ -167,8 +169,8 @@ github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= -github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= -github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= +github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc= github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= @@ -468,6 +470,8 @@ github.com/opencontainers/selinux v1.15.1 h1:ERxeh5caJvCzNAKdI8WQbJmB1LDTn4BuaAg github.com/opencontainers/selinux v1.15.1/go.mod h1:LenyElirjUHszfxrjuFqC85HIeXZKumHcKMQtnaDlQQ= github.com/package-url/packageurl-go v0.1.1 h1:KTRE0bK3sKbFKAk3yy63DpeskU7Cvs/x/Da5l+RtzyU= github.com/package-url/packageurl-go v0.1.1/go.mod h1:uQd4a7Rh3ZsVg5j0lNyAfyxIeGde9yrlhjF78GzeW0c= +github.com/paulojmdias/actionlint v0.0.0-20260825163745-7f40b72a378c h1:LUVdX5X98v3xX+Pfz/hpIBmrg+jfPbBcJmpzrA4bttU= +github.com/paulojmdias/actionlint v0.0.0-20260825163745-7f40b72a378c/go.mod h1:4BqdXZZmuvV8yxdiPKUO8Rz7CQt1RVGEAUF2q4PjuoE= github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY= github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= @@ -496,6 +500,8 @@ github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEy github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/rogpeppe/go-internal v1.16.0 h1:O9DK+vNMDVGLr2BeZqmpLeMjiMNkuXfcqntWbZV6S5g= github.com/rogpeppe/go-internal v1.16.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= diff --git a/internal/github/workflow_provider_test.go b/internal/github/workflow_provider_test.go index a2e0868da2..9737872dfe 100644 --- a/internal/github/workflow_provider_test.go +++ b/internal/github/workflow_provider_test.go @@ -157,8 +157,8 @@ func TestGitHubWorkflowProviderCatalogPreservesPartialAvailability(t *testing.T) {ID: new(int64(4)), Name: new("Disabled"), Path: new("disabled.yml"), State: new("disabled_manually")}, }, definitions: map[string]string{ - ".github/workflows/release.yml": "on:\n workflow_dispatch:\n inputs:\n target:\n type: environment\n", - ".github/workflows/ci.yml": "on: [push]\n", + ".github/workflows/release.yml": "on:\n workflow_dispatch:\n inputs:\n target:\n type: environment\njobs:\n noop:\n runs-on: ubuntu-latest\n steps:\n - run: echo ok\n", + ".github/workflows/ci.yml": "on: [push]\njobs:\n noop:\n runs-on: ubuntu-latest\n steps:\n - run: echo ok\n", ".github/workflows/malformed.yml": "on: workflow_dispatch\n---\non: workflow_dispatch\n", }, environments: []*gh.Environment{{Name: new("production")}}, @@ -229,7 +229,7 @@ func TestGitHubWorkflowProviderAbortsCatalogOnFatalDefinitionErrors(t *testing.T {ID: new(int64(2)), Name: new("Release"), Path: new(".github/workflows/release.yml"), State: new("active")}, }, definitions: map[string]string{ - ".github/workflows/release.yml": "on: workflow_dispatch\n", + ".github/workflows/release.yml": "on: workflow_dispatch\njobs:\n noop:\n runs-on: ubuntu-latest\n steps:\n - run: echo ok\n", }, definitionErrs: map[string]error{ ".github/workflows/blocked.yml": test.err, @@ -275,7 +275,7 @@ func TestGitHubWorkflowProviderKeepsPerDefinitionFailuresPartial(t *testing.T) { {ID: new(int64(2)), Name: new("Release"), Path: new(".github/workflows/release.yml"), State: new("active")}, }, definitions: map[string]string{ - ".github/workflows/release.yml": "on: workflow_dispatch\n", + ".github/workflows/release.yml": "on: workflow_dispatch\njobs:\n noop:\n runs-on: ubuntu-latest\n steps:\n - run: echo ok\n", }, definitionErrs: map[string]error{ ".github/workflows/broken.yml": test.err, @@ -391,7 +391,7 @@ func TestRoutedClientRoutesWorkflowOperationsByRepository(t *testing.T) { require := require.New(t) fallback := &workflowProviderFake{} exact := &workflowProviderFake{ - definitions: map[string]string{"release.yml": "on: workflow_dispatch"}, + definitions: map[string]string{"release.yml": "on: workflow_dispatch\njobs:\n noop:\n runs-on: ubuntu-latest\n steps:\n - run: echo ok\n"}, dispatch: &gh.WorkflowDispatchRunDetails{WorkflowRunID: new(int64(8))}, } router, err := NewHostRouter( diff --git a/internal/platform/github/workflow_definition.go b/internal/platform/github/workflow_definition.go index b88a5afb3f..2529c8b599 100644 --- a/internal/platform/github/workflow_definition.go +++ b/internal/platform/github/workflow_definition.go @@ -1,18 +1,23 @@ package github import ( - "bytes" + "errors" "fmt" - "io" "math" "slices" + "strconv" + "strings" + "github.com/rhysd/actionlint" "go.kenn.io/forge/internal/platform" - "go.yaml.in/yaml/v3" ) const MaxWorkflowDefinitionBytes = 1 << 20 +// ParseManualWorkflow reads a GitHub Actions workflow file and reports whether +// it declares a workflow_dispatch trigger. actionlint owns the workflow file +// grammar; this function only projects its dispatch inputs onto the +// provider-neutral definition and converts defaults to their declared type. func ParseManualWorkflow( name string, path string, @@ -35,59 +40,26 @@ func ParseManualWorkflow( ) } - decoder := yaml.NewDecoder(bytes.NewReader(content)) - var document yaml.Node - if err := decoder.Decode(&document); err != nil { - return definition, false, fmt.Errorf("parse workflow definition: %w", err) + workflow, parseErrors := actionlint.Parse(content) + if len(parseErrors) > 0 { + return definition, false, fmt.Errorf("parse workflow definition: %w", joinParseErrors(parseErrors)) } - var trailing yaml.Node - if err := decoder.Decode(&trailing); err != io.EOF { - if err != nil { - return definition, false, fmt.Errorf("parse trailing workflow definition: %w", err) - } - return definition, false, fmt.Errorf("workflow definition must contain one document") - } - if len(document.Content) != 1 { - return definition, false, fmt.Errorf("workflow definition must contain one document") - } - root := document.Content[0] - if err := rejectAliases(root); err != nil { - return definition, false, err - } - if root.Kind != yaml.MappingNode { - return definition, false, fmt.Errorf("workflow definition must be a mapping") + if workflow == nil { + return definition, false, errors.New("parse workflow definition: empty document") } - onNode, found, err := mappingValue(root, "on") - if err != nil { - return definition, false, fmt.Errorf("parse workflow triggers: %w", err) - } - if !found { - return definition, false, nil - } - - manualNode, manual, err := manualTrigger(onNode) - if err != nil { - return definition, false, err + var dispatch *actionlint.WorkflowDispatchEvent + for _, event := range workflow.On { + if candidate, ok := event.(*actionlint.WorkflowDispatchEvent); ok { + dispatch = candidate + break + } } - if !manual { + if dispatch == nil { return definition, false, nil } - if manualNode == nil || isNull(manualNode) { - return definition, true, nil - } - if manualNode.Kind != yaml.MappingNode { - return definition, false, fmt.Errorf("workflow_dispatch configuration must be a mapping") - } - inputsNode, found, err := workflowDispatchInputs(manualNode) - if err != nil { - return definition, false, fmt.Errorf("parse workflow_dispatch: %w", err) - } - if !found || isNull(inputsNode) { - return definition, true, nil - } - inputs, err := parseWorkflowInputs(inputsNode) + inputs, err := convertDispatchInputs(dispatch.Inputs) if err != nil { return definition, false, err } @@ -95,317 +67,116 @@ func ParseManualWorkflow( return definition, true, nil } -func rejectAliases(root *yaml.Node) error { - stack := []*yaml.Node{root} - for len(stack) > 0 { - last := len(stack) - 1 - node := stack[last] - stack = stack[:last] - if node.Kind == yaml.AliasNode { - return fmt.Errorf("workflow definition must not contain YAML aliases") - } - stack = append(stack, node.Content...) +func joinParseErrors(parseErrors []*actionlint.Error) error { + messages := make([]string, 0, len(parseErrors)) + for _, parseError := range parseErrors { + messages = append(messages, fmt.Sprintf("line %d: %s", parseError.Line, parseError.Message)) } - return nil + return errors.New(strings.Join(messages, "; ")) } -func mappingValue(mapping *yaml.Node, wanted string) (*yaml.Node, bool, error) { - if mapping.Kind != yaml.MappingNode { - return nil, false, fmt.Errorf("expected mapping") +// convertDispatchInputs returns inputs in file declaration order. actionlint +// lowercases the map keys, so the declared name is read from each input. +func convertDispatchInputs(inputs map[string]*actionlint.DispatchInput) ([]platform.WorkflowInput, error) { + if len(inputs) == 0 { + return nil, nil } - var value *yaml.Node - found := false - seen := make(map[string]struct{}, len(mapping.Content)/2) - for index := 0; index < len(mapping.Content); index += 2 { - key := mapping.Content[index] - if key.Kind != yaml.ScalarNode || key.Tag != "!!str" { - return nil, false, fmt.Errorf("mapping keys must be strings") - } - if _, duplicate := seen[key.Value]; duplicate { - return nil, false, fmt.Errorf("duplicate key %q", key.Value) - } - seen[key.Value] = struct{}{} - if key.Value == wanted { - value = mapping.Content[index+1] - found = true - } + ordered := make([]*actionlint.DispatchInput, 0, len(inputs)) + for _, input := range inputs { + ordered = append(ordered, input) } - return value, found, nil -} - -func workflowDispatchInputs(mapping *yaml.Node) (*yaml.Node, bool, error) { - var inputs *yaml.Node - found := false - for index := 0; index < len(mapping.Content); index += 2 { - key := mapping.Content[index] - if key.Kind != yaml.ScalarNode || key.Tag != "!!str" { - return nil, false, fmt.Errorf("workflow_dispatch keys must be strings") - } - if key.Value != "inputs" { - return nil, false, fmt.Errorf("unknown workflow_dispatch field %q", key.Value) - } - if found { - return nil, false, fmt.Errorf("duplicate workflow_dispatch field %q", key.Value) + slices.SortFunc(ordered, func(a, b *actionlint.DispatchInput) int { + if a.Name.Pos.Line != b.Name.Pos.Line { + return a.Name.Pos.Line - b.Name.Pos.Line } - inputs = mapping.Content[index+1] - found = true - } - return inputs, found, nil -} + return a.Name.Pos.Col - b.Name.Pos.Col + }) -func manualTrigger(onNode *yaml.Node) (*yaml.Node, bool, error) { - switch onNode.Kind { - case yaml.ScalarNode: - if isNull(onNode) { - return nil, false, nil - } - if onNode.Tag != "!!str" { - return nil, false, fmt.Errorf("workflow trigger must be a string") - } - return nil, onNode.Value == "workflow_dispatch", nil - case yaml.SequenceNode: - manual := false - for _, event := range onNode.Content { - if event.Kind != yaml.ScalarNode || event.Tag != "!!str" { - return nil, false, fmt.Errorf("workflow trigger sequence must contain strings") - } - if event.Value == "workflow_dispatch" { - manual = true - } - } - return nil, manual, nil - case yaml.MappingNode: - manualNode, found, err := mappingValue(onNode, "workflow_dispatch") + converted := make([]platform.WorkflowInput, 0, len(ordered)) + for _, input := range ordered { + value, err := convertDispatchInput(input) if err != nil { - return nil, false, fmt.Errorf("parse workflow trigger mapping: %w", err) + return nil, fmt.Errorf("parse workflow input %q: %w", input.Name.Value, err) } - return manualNode, found, nil - default: - return nil, false, fmt.Errorf("workflow trigger must be a scalar, sequence, or mapping") + converted = append(converted, value) } + return converted, nil } -func parseWorkflowInputs(inputsNode *yaml.Node) ([]platform.WorkflowInput, error) { - if inputsNode.Kind != yaml.MappingNode { - return nil, fmt.Errorf("workflow_dispatch inputs must be a mapping") +func convertDispatchInput(input *actionlint.DispatchInput) (platform.WorkflowInput, error) { + converted := platform.WorkflowInput{Name: input.Name.Value, Type: inputType(input.Type)} + if input.Description != nil { + converted.Description = input.Description.Value } - - inputs := make([]platform.WorkflowInput, 0, len(inputsNode.Content)/2) - seen := make(map[string]struct{}, len(inputsNode.Content)/2) - for index := 0; index < len(inputsNode.Content); index += 2 { - nameNode := inputsNode.Content[index] - definitionNode := inputsNode.Content[index+1] - if nameNode.Kind != yaml.ScalarNode || nameNode.Tag != "!!str" { - return nil, fmt.Errorf("workflow input names must be strings") - } - if _, duplicate := seen[nameNode.Value]; duplicate { - return nil, fmt.Errorf("duplicate workflow input %q", nameNode.Value) + if input.Required != nil { + if input.Required.Expression != nil { + return converted, errors.New("required must be a literal boolean") } - seen[nameNode.Value] = struct{}{} - - input, err := parseWorkflowInput(nameNode.Value, definitionNode) - if err != nil { - return nil, fmt.Errorf("parse workflow input %q: %w", nameNode.Value, err) - } - inputs = append(inputs, input) - } - return inputs, nil -} - -func parseWorkflowInput(name string, definitionNode *yaml.Node) (platform.WorkflowInput, error) { - input := platform.WorkflowInput{Name: name, Type: platform.WorkflowInputString} - if isNull(definitionNode) { - return input, nil - } - if definitionNode.Kind != yaml.MappingNode { - return input, fmt.Errorf("definition must be a mapping") + converted.Required = input.Required.Value } - - fields, err := inputFields(definitionNode) - if err != nil { - return input, err - } - if node, ok := fields["description"]; ok { - value, err := stringScalar(node, "description") - if err != nil { - return input, err + for _, option := range input.Options { + if slices.Contains(converted.Options, option.Value) { + return converted, fmt.Errorf("duplicate choice option %q", option.Value) } - input.Description = value + converted.Options = append(converted.Options, option.Value) } - if node, ok := fields["required"]; ok { - if node.Kind != yaml.ScalarNode || node.Tag != "!!bool" { - return input, fmt.Errorf("required must be a boolean") - } - if err := node.Decode(&input.Required); err != nil { - return input, fmt.Errorf("decode required: %w", err) - } - } - if node, ok := fields["type"]; ok { - value, err := stringScalar(node, "type") - if err != nil { - return input, err - } - input.Type = platform.WorkflowInputType(value) + if converted.Type == platform.WorkflowInputChoice && len(converted.Options) == 0 { + return converted, errors.New("choice input requires options") } - if !supportedWorkflowInputType(input.Type) { - return input, fmt.Errorf("unsupported type %q", input.Type) + if converted.Type != platform.WorkflowInputChoice && len(converted.Options) > 0 { + return converted, errors.New("options are only valid for choice inputs") } - - optionsNode, hasOptions := fields["options"] - if input.Type == platform.WorkflowInputChoice { - if !hasOptions { - return input, fmt.Errorf("choice input requires options") - } - options, err := choiceOptions(optionsNode) - if err != nil { - return input, err - } - input.Options = options - } else if hasOptions { - return input, fmt.Errorf("options are only valid for choice inputs") + if input.Default == nil { + return converted, nil } - if defaultNode, ok := fields["default"]; ok { - value, err := scalarDefault(defaultNode) - if err != nil { - return input, err - } - if !defaultMatchesType(value, input.Type) { - return input, fmt.Errorf("default does not match type %q", input.Type) - } - if input.Type == platform.WorkflowInputChoice && !contains(input.Options, value.(string)) { - return input, fmt.Errorf("default %q is not one of the choice options", value) - } - input.Default = value - input.HasDefault = true + value, err := typedDefault(input.Default.Value, converted.Type) + if err != nil { + return converted, err } - return input, nil -} - -func inputFields(mapping *yaml.Node) (map[string]*yaml.Node, error) { - fields := make(map[string]*yaml.Node, len(mapping.Content)/2) - for index := 0; index < len(mapping.Content); index += 2 { - key := mapping.Content[index] - if key.Kind != yaml.ScalarNode || key.Tag != "!!str" { - return nil, fmt.Errorf("definition keys must be strings") - } - switch key.Value { - case "description", "required", "type", "default", "options": - default: - return nil, fmt.Errorf("unknown input definition field %q", key.Value) - } - if _, duplicate := fields[key.Value]; duplicate { - return nil, fmt.Errorf("duplicate definition key %q", key.Value) - } - fields[key.Value] = mapping.Content[index+1] + if converted.Type == platform.WorkflowInputChoice && !slices.Contains(converted.Options, value.(string)) { + return converted, fmt.Errorf("default %q is not one of the choice options", value) } - return fields, nil + converted.Default = value + converted.HasDefault = true + return converted, nil } -func supportedWorkflowInputType(inputType platform.WorkflowInputType) bool { - switch inputType { - case platform.WorkflowInputString, - platform.WorkflowInputNumber, - platform.WorkflowInputBoolean, - platform.WorkflowInputChoice, - platform.WorkflowInputEnvironment: - return true +func inputType(kind actionlint.WorkflowDispatchEventInputType) platform.WorkflowInputType { + switch kind { + case actionlint.WorkflowDispatchEventInputTypeNumber: + return platform.WorkflowInputNumber + case actionlint.WorkflowDispatchEventInputTypeBoolean: + return platform.WorkflowInputBoolean + case actionlint.WorkflowDispatchEventInputTypeChoice: + return platform.WorkflowInputChoice + case actionlint.WorkflowDispatchEventInputTypeEnvironment: + return platform.WorkflowInputEnvironment default: - return false + return platform.WorkflowInputString } } -func choiceOptions(optionsNode *yaml.Node) ([]string, error) { - if optionsNode.Kind != yaml.SequenceNode { - return nil, fmt.Errorf("choice options must be a sequence") - } - if len(optionsNode.Content) == 0 { - return nil, fmt.Errorf("choice input requires at least one option") - } - - options := make([]string, 0, len(optionsNode.Content)) - seen := make(map[string]struct{}, len(optionsNode.Content)) - for _, optionNode := range optionsNode.Content { - option, err := stringScalar(optionNode, "choice option") +// typedDefault converts the raw YAML scalar text into the value shape the +// dispatch form and validation expect for the declared input type. +func typedDefault(raw string, kind platform.WorkflowInputType) (any, error) { + switch kind { + case platform.WorkflowInputBoolean: + value, err := strconv.ParseBool(raw) if err != nil { - return nil, err - } - if _, duplicate := seen[option]; duplicate { - return nil, fmt.Errorf("duplicate choice option %q", option) - } - seen[option] = struct{}{} - options = append(options, option) - } - return options, nil -} - -func scalarDefault(node *yaml.Node) (any, error) { - if node.Kind != yaml.ScalarNode { - return nil, fmt.Errorf("default must be a scalar") - } - - switch node.Tag { - case "!!str": - return node.Value, nil - case "!!bool": - var value bool - if err := node.Decode(&value); err != nil { - return nil, fmt.Errorf("decode boolean default: %w", err) + return nil, fmt.Errorf("default does not match type %q", kind) } return value, nil - case "!!int": - var value any - if err := node.Decode(&value); err != nil { - return nil, fmt.Errorf("decode integer default: %w", err) - } - return value, nil - case "!!float": - var value float64 - if err := node.Decode(&value); err != nil { - return nil, fmt.Errorf("decode number default: %w", err) + case platform.WorkflowInputNumber: + if value, err := strconv.ParseInt(raw, 10, 64); err == nil { + return int(value), nil } - if math.IsInf(value, 0) || math.IsNaN(value) { - return nil, fmt.Errorf("number default must be finite") + value, err := strconv.ParseFloat(raw, 64) + if err != nil || math.IsInf(value, 0) || math.IsNaN(value) { + return nil, fmt.Errorf("default does not match type %q", kind) } return value, nil default: - return nil, fmt.Errorf("default has unsupported scalar type %q", node.Tag) + return raw, nil } } - -func defaultMatchesType(value any, inputType platform.WorkflowInputType) bool { - switch inputType { - case platform.WorkflowInputString, platform.WorkflowInputChoice, platform.WorkflowInputEnvironment: - _, ok := value.(string) - return ok - case platform.WorkflowInputBoolean: - _, ok := value.(bool) - return ok - case platform.WorkflowInputNumber: - switch value.(type) { - case int, int8, int16, int32, int64, - uint, uint8, uint16, uint32, uint64, - float32, float64: - return true - default: - return false - } - default: - return false - } -} - -func stringScalar(node *yaml.Node, field string) (string, error) { - if node.Kind != yaml.ScalarNode || node.Tag != "!!str" { - return "", fmt.Errorf("%s must be a string", field) - } - return node.Value, nil -} - -func isNull(node *yaml.Node) bool { - return node.Kind == yaml.ScalarNode && node.Tag == "!!null" -} - -func contains(values []string, wanted string) bool { - return slices.Contains(values, wanted) -} diff --git a/internal/platform/github/workflow_definition_test.go b/internal/platform/github/workflow_definition_test.go index 061298d202..5c0338829c 100644 --- a/internal/platform/github/workflow_definition_test.go +++ b/internal/platform/github/workflow_definition_test.go @@ -9,6 +9,14 @@ import ( "go.kenn.io/forge/internal/platform" ) +// jobsSection satisfies the workflow grammar so each fixture exercises only +// its trigger and inputs. +const jobsSection = "jobs:\n noop:\n runs-on: ubuntu-latest\n steps:\n - run: echo ok\n" + +func withJobs(content string) []byte { + return []byte(content + jobsSection) +} + func TestParseManualWorkflow(t *testing.T) { t.Run("preserves metadata and typed inputs in declaration order", func(t *testing.T) { content := []byte(`name: Release @@ -34,7 +42,7 @@ on: environment: required: true type: environment -`) +` + jobsSection) definition, manual, err := ParseManualWorkflow( "Release", @@ -133,7 +141,7 @@ on: assert := assert.New(t) require := require.New(t) definition, manual, err := ParseManualWorkflow( - "CI", ".github/workflows/ci.yml", "https://example.test/ci", "sha", []byte(test.content), + "CI", ".github/workflows/ci.yml", "https://example.test/ci", "sha", withJobs(test.content), ) require.NoError(err) assert.Equal(test.manual, manual) @@ -150,95 +158,83 @@ on: }{ { name: "unsupported input type", - content: []byte("on:\n workflow_dispatch:\n inputs:\n target:\n type: object\n"), + content: withJobs("on:\n workflow_dispatch:\n inputs:\n target:\n type: object\n"), }, { name: "duplicate input key", - content: []byte("on:\n workflow_dispatch:\n inputs:\n target:\n type: string\n target:\n type: string\n"), + content: withJobs("on:\n workflow_dispatch:\n inputs:\n target:\n type: string\n target:\n type: string\n"), }, { name: "choice without options", - content: []byte("on:\n workflow_dispatch:\n inputs:\n channel:\n type: choice\n"), + content: withJobs("on:\n workflow_dispatch:\n inputs:\n channel:\n type: choice\n"), }, { name: "choice with empty options", - content: []byte("on:\n workflow_dispatch:\n inputs:\n channel:\n type: choice\n options: []\n"), + content: withJobs("on:\n workflow_dispatch:\n inputs:\n channel:\n type: choice\n options: []\n"), }, { name: "default outside choices", - content: []byte("on:\n workflow_dispatch:\n inputs:\n channel:\n type: choice\n options: [stable, beta]\n default: nightly\n"), - }, - { - name: "alias", - content: []byte("dispatch: &dispatch workflow_dispatch\non: *dispatch\n"), + content: withJobs("on:\n workflow_dispatch:\n inputs:\n channel:\n type: choice\n options: [stable, beta]\n default: nightly\n"), }, { name: "mapping default", - content: []byte("on:\n workflow_dispatch:\n inputs:\n target:\n type: string\n default: {name: main}\n"), + content: withJobs("on:\n workflow_dispatch:\n inputs:\n target:\n type: string\n default: {name: main}\n"), }, { name: "sequence default", - content: []byte("on:\n workflow_dispatch:\n inputs:\n target:\n type: string\n default: [main]\n"), + content: withJobs("on:\n workflow_dispatch:\n inputs:\n target:\n type: string\n default: [main]\n"), }, { name: "scalar choice options", - content: []byte("on:\n workflow_dispatch:\n inputs:\n channel:\n type: choice\n options: stable\n"), + content: withJobs("on:\n workflow_dispatch:\n inputs:\n channel:\n type: choice\n options: stable\n"), }, { name: "non scalar choice option", - content: []byte("on:\n workflow_dispatch:\n inputs:\n channel:\n type: choice\n options: [stable, {name: beta}]\n"), + content: withJobs("on:\n workflow_dispatch:\n inputs:\n channel:\n type: choice\n options: [stable, {name: beta}]\n"), }, { name: "boolean input with string default", - content: []byte("on:\n workflow_dispatch:\n inputs:\n dry_run:\n type: boolean\n default: no\n"), + content: withJobs("on:\n workflow_dispatch:\n inputs:\n dry_run:\n type: boolean\n default: no\n"), }, { name: "number input with string default", - content: []byte("on:\n workflow_dispatch:\n inputs:\n retries:\n type: number\n default: two\n"), - }, - { - name: "string input with boolean default", - content: []byte("on:\n workflow_dispatch:\n inputs:\n target:\n type: string\n default: false\n"), + content: withJobs("on:\n workflow_dispatch:\n inputs:\n retries:\n type: number\n default: two\n"), }, { name: "non boolean required", - content: []byte("on:\n workflow_dispatch:\n inputs:\n target:\n required: yes\n"), - }, - { - name: "non string description", - content: []byte("on:\n workflow_dispatch:\n inputs:\n target:\n description: 42\n"), + content: withJobs("on:\n workflow_dispatch:\n inputs:\n target:\n required: yes\n"), }, { name: "non mapping inputs", - content: []byte("on:\n workflow_dispatch:\n inputs: [target]\n"), + content: withJobs("on:\n workflow_dispatch:\n inputs: [target]\n"), }, { name: "non mapping input definition", - content: []byte("on:\n workflow_dispatch:\n inputs:\n target: string\n"), + content: withJobs("on:\n workflow_dispatch:\n inputs:\n target: string\n"), }, { name: "non scalar sequence trigger after workflow dispatch", - content: []byte("on: [workflow_dispatch, {push: null}]\n"), - }, - { - name: "multiple YAML documents", - content: []byte("on: workflow_dispatch\n---\nname: trailing\n"), + content: withJobs("on: [workflow_dispatch, {push: null}]\n"), }, { name: "unknown workflow dispatch field", - content: []byte("on:\n workflow_dispatch:\n inputz: {}\n"), + content: withJobs("on:\n workflow_dispatch:\n inputz: {}\n"), }, { name: "misspelled required field", - content: []byte("on:\n workflow_dispatch:\n inputs:\n target:\n requred: true\n"), + content: withJobs("on:\n workflow_dispatch:\n inputs:\n target:\n requred: true\n"), }, { name: "misspelled default field", - content: []byte("on:\n workflow_dispatch:\n inputs:\n target:\n defualt: main\n"), + content: withJobs("on:\n workflow_dispatch:\n inputs:\n target:\n defualt: main\n"), }, { name: "malformed YAML", - content: []byte("on: [workflow_dispatch\n"), + content: withJobs("on: [workflow_dispatch\n"), + }, + { + name: "missing jobs section", + content: []byte("on: workflow_dispatch\n"), }, { name: "payload larger than limit", From d0ae4b03eb6e48c27c9fc02cb2a9be1fdae70599 Mon Sep 17 00:00:00 2001 From: Marius van Niekerk Date: Fri, 4 Sep 2026 12:35:00 -0400 Subject: [PATCH 41/41] refactor: move workflow dispatch follow-through to the server The contributed frontend carried a 1,200-line Effect service that polled workflow runs, queued dispatches per repository, and matched a dispatched run by actor and time window in the browser. That logic depends on provider state the server already owns, and a browser tab is the wrong place to keep it alive. The dispatch route now returns a dispatch_id and, after the provider accepts, a server goroutine locates the created run, watches it until it completes, and publishes workflow_dispatch_progress events over the existing SSE hub. The browser store shrinks to fetch-on-demand reads plus an event handler that updates the dispatch cycle and the run list in place. No workflow polling remains in the frontend. The workflowapi handler takes a small Runtime interface for publishing events and running background work instead of function fields. The "uncertain" outcome no longer shows candidate runs, since the browser no longer scans runs itself; it tells the user to check the provider. Co-Authored-By: Claude Fable 5.1 --- context/frontend-effect.md | 37 +- context/retries-and-backoffs.md | 11 +- context/server-runtime.md | 5 + context/ui-interaction-contracts.md | 14 +- frontend/openapi/openapi.yaml | 6 +- ...lDetail.workflow-actions.browser.svelte.ts | 15 +- frontend/src/lib/api/generated/schema.ts | 2 +- frontend/src/lib/app-stores.svelte.ts | 1 + frontend/src/lib/app/layer.ts | 2 - .../lib/components/actions/ActionsPage.svelte | 32 +- .../components/actions/ActionsPage.test.ts | 31 +- .../actions/WorkflowDispatchDialog.test.ts | 23 +- .../actions/WorkflowDispatchForm.svelte | 21 +- .../actions/WorkflowDispatchForm.test.ts | 1 - .../components/actions/WorkflowRunList.svelte | 5 +- .../actions/WorkflowRunList.test.ts | 15 +- .../workflow-dispatch-presentation.test.ts | 44 +- .../actions/workflow-dispatch-presentation.ts | 75 +- .../lib/components/detail/PullDetail.svelte | 11 +- .../lib/components/detail/PullDetail.test.ts | 6 +- frontend/src/lib/stores/events.svelte.ts | 6 + .../lib/stores/provider-events-workflow.ts | 25 +- .../stores/workflow-actions-workflow.test.ts | 1339 ----------------- .../lib/stores/workflow-actions-workflow.ts | 1197 --------------- .../stores/workflow-actions.svelte.test.ts | 449 +++--- .../src/lib/stores/workflow-actions.svelte.ts | 620 +++++--- frontend/vitest.node-files.ts | 2 - internal/apiclient/generated/client.gen.go | 10 +- internal/github/sync.go | 3 +- internal/github/workflow_provider_test.go | 2 +- internal/platform/types.go | 9 +- internal/server/server.go | 13 + .../server/workflowapi/dispatch_follow.go | 242 +++ internal/server/workflowapi/handler.go | 34 + internal/server/workflowapi/routes.go | 12 +- internal/server/workflowapi/routes_test.go | 185 ++- internal/server/workflowapi/types.go | 11 +- 37 files changed, 1364 insertions(+), 3152 deletions(-) delete mode 100644 frontend/src/lib/stores/workflow-actions-workflow.test.ts delete mode 100644 frontend/src/lib/stores/workflow-actions-workflow.ts create mode 100644 internal/server/workflowapi/dispatch_follow.go diff --git a/context/frontend-effect.md b/context/frontend-effect.md index 8cd59be335..98d0fc1561 100644 --- a/context/frontend-effect.md +++ b/context/frontend-effect.md @@ -46,27 +46,22 @@ service is the supported tool here. uninterruptible acquisition handoff. After an ordered queue admits a non-idempotent command, pending-state publication and executor release are likewise one uninterruptible handoff. -- Workflow Actions is one app-scoped Effect service by canonical repository; - presenters only claim demand, while polling, dispatch, and accepted workflow - identity survive them (`frontend/src/lib/stores/workflow-actions-workflow.ts::WorkflowActionsWorkflowLive`). -- Selection interrupts the prior run loop, and stale responses cannot replace - its runs; disabling clears owners/fibers without cancelling admitted dispatch - (`frontend/src/lib/stores/workflow-actions-workflow.ts::selectWorkflow`). -- Workflow dispatch reconciliation starts its window at POST execution and matches - only a resolved personal-write actor; unknown actors never wildcard another user's run - (`frontend/src/lib/stores/workflow-actions-workflow.ts::matchingCandidates`). -- Success, rejection, uncertainty, and definition conflict leave presentation only through - the explicit new-cycle command; deliberate retry returns to fresh confirmation and never posts - (`frontend/src/lib/stores/workflow-actions-workflow.ts::newDispatchCycle`). -- Dispatch ordering is FIFO per canonical repository, not global: same-repository writes serialize - while unrelated repositories post concurrently, and every queue lives for the app scope - (`frontend/src/lib/stores/workflow-actions-workflow.ts::dispatchQueueFor`). -- Definition-reload failures are workflow-cycle state, separate from general read errors; - successful run polling cannot clear them, and only dialog close or the next reload cycle does - (`frontend/src/lib/stores/workflow-actions-workflow.ts::clearCatalogRefreshError`). -- Forced definition reloads are latest-cycle-wins per repository and workflow; - stale success or failure cannot replace catalog data, recovery error, or conflict state - (`frontend/src/lib/stores/workflow-actions-workflow.ts::refreshCatalog`). +- Workflow Actions is a plain app-scoped store that reads on demand and applies + server events; it owns no polling loops, queues, or reconciliation. Dispatch + follow-through (locating the run, watching it finish) lives on the server and + arrives as `workflow_dispatch_progress` events keyed by `dispatch_id` + (`frontend/src/lib/stores/workflow-actions.svelte.ts::applyDispatchProgress`, + `internal/server/workflowapi/dispatch_follow.go::Handler.followDispatch`). +- Reads are latest-wins per repository through generation counters; a stale catalog + or run response never replaces newer data + (`frontend/src/lib/stores/workflow-actions.svelte.ts::selectWorkflow`). +- One dispatch cycle exists per workflow. Success, rejection, uncertainty, and + definition conflict leave presentation only through the explicit new-cycle + command; retry returns to fresh confirmation and never replays the POST + (`frontend/src/lib/stores/workflow-actions.svelte.ts::newDispatchCycle`). +- Definition-reload failures are cycle state separate from general read errors; + a successful reload clears that workflow's cycle + (`frontend/src/lib/stores/workflow-actions.svelte.ts::refreshCatalog`). - Use Effect concurrency, queues, fibers, schedules, and interruption instead of bespoke Promise generations, overlapping timers, or boolean race guards. Preserve latest-wins, single-flight, ordered, or lossless semantics explicitly; diff --git a/context/retries-and-backoffs.md b/context/retries-and-backoffs.md index 9ce2f5400d..17b6f1b025 100644 --- a/context/retries-and-backoffs.md +++ b/context/retries-and-backoffs.md @@ -72,12 +72,11 @@ The archive worker uses the backoff schedule type only as an idle delay calculat not as a retry wrapper: idle passes double up to a five-minute cap and any wake or worked pass resets it (`internal/github/sync.go::runArchiveLoop`). -- Manual workflow dispatch is non-idempotent and never retried; reconcile - accepted or uncertain outcomes by reads instead of replaying POST - (`frontend/src/lib/stores/workflow-actions-workflow.ts::dispatchQueueFor`). -- Selected runs poll every 5 seconds while active; idle runs and failed catalog - reads wait 30 seconds. Removing demand or disabling Actions interrupts it - (`frontend/src/lib/stores/workflow-actions-workflow.ts::waitForPoll`). +- Manual workflow dispatch is non-idempotent and never retried by either side. + The server locates the created run by reading runs for about a minute, then + watches that run at a fixed interval until it completes or thirty minutes pass; + the browser never polls workflow state + (`internal/server/workflowapi/dispatch_follow.go::Handler.followDispatch`). ## Long-lived stream recovery diff --git a/context/server-runtime.md b/context/server-runtime.md index b196422112..79eb577b2a 100644 --- a/context/server-runtime.md +++ b/context/server-runtime.md @@ -188,6 +188,11 @@ and the root event stream. ## Event Replay +- Workflow dispatch follow-through is server-owned background work: after a + provider accepts a dispatch, a tracked goroutine locates the run and watches it + to completion, publishing `workflow_dispatch_progress` events keyed by the + response's `dispatch_id`. Clients never poll workflow runs + (`internal/server/workflowapi/dispatch_follow.go::Handler.followDispatch`). - SSE event IDs are process-scoped replay cursors, not durable sequence numbers. Reconnects may replay only IDs retained by the current process's ring (`internal/server/event_hub.go::EventHub.ReplaySnapshotSince`). diff --git a/context/ui-interaction-contracts.md b/context/ui-interaction-contracts.md index 6fc8a0517b..b653b49c8b 100644 --- a/context/ui-interaction-contracts.md +++ b/context/ui-interaction-contracts.md @@ -278,9 +278,11 @@ Persisted controls must state their scope clearly. (`frontend/src/lib/components/actions/ActionsPage.svelte::workflowReadErrorMessage`). - Run reads belong to the selected workflow: selection replaces the prior run projection and every generated request carries that workflow ID - (`frontend/src/lib/stores/workflow-actions-workflow.ts::readRuns`). -- Accepted dispatch wakes reconciliation, so a new run does not wait for the - prior idle interval (`frontend/src/lib/stores/workflow-actions-workflow.ts::restartRepositoryLoop`). + (`frontend/src/lib/stores/workflow-actions.svelte.ts::selectWorkflow`). +- A dispatched run appears in the list from the dispatch response or the first + `workflow_dispatch_progress` event, and later events update it in place; the + list is otherwise refreshed only by user action + (`frontend/src/lib/stores/workflow-actions.svelte.ts::applyDispatchProgress`). - PR Actions defaults open same-repository pulls to the head branch, but forks and non-open states to the target; workflows remain on merged pulls (`frontend/src/lib/components/detail/PullDetail.svelte::workflowInitialRef`). @@ -886,9 +888,9 @@ Rows that contain buttons, links, or toggles need clear event ownership. - Catalog reads use consumer-local owners: picker teardown or route replacement may cancel only that consumer, never review-run state or sibling repository resolution (`frontend/src/lib/components/roborev/RepoTreePicker.svelte::owner`). -- Workflow Actions seeds top-level refs from repository authority, preserves loaded older run - pages while polling page one, and keys lazy jobs by run so sibling disclosures release independently - (`frontend/src/lib/components/actions/ActionsPage.svelte`). +- Workflow Actions seeds top-level refs from repository authority and reads jobs lazily once per + expanded run; collapsing a run keeps its jobs and never refetches + (`frontend/src/lib/components/actions/ActionsPage.svelte::expandRun`). - Docs publish commands snapshot folder and message and remain application-owned after replacement; same-folder surfaces adopt pending or unacknowledged failure state, while completed success is never replayed into a later session (`frontend/src/lib/stores/docs-workflow.ts::DocsWorkflowService`). diff --git a/frontend/openapi/openapi.yaml b/frontend/openapi/openapi.yaml index 52e1cdda80..143550074c 100644 --- a/frontend/openapi/openapi.yaml +++ b/frontend/openapi/openapi.yaml @@ -9214,13 +9214,13 @@ components: type: boolean actor: type: string - locating_run: - type: boolean + dispatch_id: + type: string run: $ref: "#/components/schemas/WorkflowRunResponse" required: - accepted - - locating_run + - dispatch_id type: object WorkflowEnvironmentResponse: additionalProperties: false diff --git a/frontend/src/PullDetail.workflow-actions.browser.svelte.ts b/frontend/src/PullDetail.workflow-actions.browser.svelte.ts index 7e7127ae69..29b9724121 100644 --- a/frontend/src/PullDetail.workflow-actions.browser.svelte.ts +++ b/frontend/src/PullDetail.workflow-actions.browser.svelte.ts @@ -204,20 +204,17 @@ describe("PullDetail provider workflow action geometry", () => { settings.setModeVisibility({ ...settings.getModeVisibility(), actions: true }); settings.setDetailSettings({ ...settings.getDetailSettings(), initial_timeline_entry_limit: 250 }); const workflowActions = { - claimRepository: vi.fn(() => { + loadCatalog: vi.fn(() => { catalogClaimed = true; }), - releaseRepository: vi.fn(() => { - catalogClaimed = false; - }), - selectWorkflow: vi.fn(), refreshCatalog: vi.fn(), clearCatalogRefreshError: vi.fn(), + selectWorkflow: vi.fn(), loadMoreRuns: vi.fn(), - newDispatchCycle: vi.fn(), - expandRun: vi.fn(), - collapseRun: vi.fn(), + loadJobs: vi.fn(), dispatch: vi.fn(), + newDispatchCycle: vi.fn(), + applyDispatchProgress: vi.fn(), setEnabled: vi.fn(), getSnapshot: vi.fn(() => null), getCatalog: () => (catalogClaimed ? { repo: detail.repo, environments: [], workflows: [workflow] } : null), @@ -226,7 +223,7 @@ describe("PullDetail provider workflow action geometry", () => { getRuns: vi.fn(() => []), getJobs: vi.fn(() => []), getLoading: vi.fn(() => ({ catalog: false, runs: false, jobs: [] })), - getDispatches: () => [], + getDispatch: () => null, }; const detailStore = { loadDetail: vi.fn(), diff --git a/frontend/src/lib/api/generated/schema.ts b/frontend/src/lib/api/generated/schema.ts index 2d7ba83926..af01f0017c 100644 --- a/frontend/src/lib/api/generated/schema.ts +++ b/frontend/src/lib/api/generated/schema.ts @@ -9378,7 +9378,7 @@ export interface components { readonly $schema?: string; accepted: boolean; actor?: string; - locating_run: boolean; + dispatch_id: string; run?: components["schemas"]["WorkflowRunResponse"]; }; WorkflowEnvironmentResponse: { diff --git a/frontend/src/lib/app-stores.svelte.ts b/frontend/src/lib/app-stores.svelte.ts index bc4dd87f8c..cca8d9df59 100644 --- a/frontend/src/lib/app-stores.svelte.ts +++ b/frontend/src/lib/app-stores.svelte.ts @@ -342,6 +342,7 @@ export function createAppStores(options: AppStoreOptions): AppStoreComposition { } return Effect.void; }, + onWorkflowDispatchProgress: (event) => Effect.sync(() => workflowActions.applyDispatchProgress(event)), onDeferredMergeCompleted: (event) => Effect.gen(function* () { const refreshes: Array> = [ diff --git a/frontend/src/lib/app/layer.ts b/frontend/src/lib/app/layer.ts index 9cde2bbe5e..b616401294 100644 --- a/frontend/src/lib/app/layer.ts +++ b/frontend/src/lib/app/layer.ts @@ -31,7 +31,6 @@ import { ProjectMutationWorkflowLive } from "../components/terminal/project-muta import { WorkspaceRuntimeWorkflowLive } from "../components/terminal/workspace-runtime-workflow.js"; import { RepoSummaryWorkflowLive } from "../components/repositories/repo-summary-workflow.js"; import { ToolingStatusWorkflowLive } from "../stores/tooling-status-workflow.js"; -import { WorkflowActionsWorkflowLive } from "../stores/workflow-actions-workflow.js"; export function makeAppLiveLayer(generatedApiLayer: Layer.Layer) { const browserBoundaryLive = Layer.mergeAll( @@ -69,7 +68,6 @@ export function makeAppLiveLayer(generatedApiLayer: Layer.Layer) { WorkspaceRuntimeWorkflowLive, RepoSummaryWorkflowLive, ToolingStatusWorkflowLive, - WorkflowActionsWorkflowLive, ); const applicationWorkflowsLive = Layer.mergeAll(SettingsWorkflowLive, providerWorkflowsLive); diff --git a/frontend/src/lib/components/actions/ActionsPage.svelte b/frontend/src/lib/components/actions/ActionsPage.svelte index dc05bf8608..69e2fc231b 100644 --- a/frontend/src/lib/components/actions/ActionsPage.svelte +++ b/frontend/src/lib/components/actions/ActionsPage.svelte @@ -27,9 +27,6 @@ } from "./workflow-dispatch-presentation.js"; import WorkflowRunList from "./WorkflowRunList.svelte"; - const repositoryOwner = "actions-page:repository"; - const expandedRunIds = new Set(); - const runtime = getAppRuntime(); const { workflowActions } = getStores(); @@ -113,17 +110,14 @@ } function selectRepository(summary: RepoSummaryCard): void { - releaseAllRunOwners(); selectedRepositoryKey = repoStateKey(summary); } function selectWorkflow(workflowId: string): void { if (!selectedRef) return; - releaseAllRunOwners(); workflowActions.selectWorkflow(selectedRef, workflowId); } - function submitWorkflow(request: WorkflowDispatchRequest): void { if (!selectedRef || !selectedWorkflow) return; workflowActions.dispatch({ @@ -147,20 +141,7 @@ function expandRun(runId: string): void { if (!selectedRef) return; - expandedRunIds.add(runId); - workflowActions.expandRun(`actions-page:jobs:${runId}`, selectedRef, runId); - } - - function collapseRun(runId: string): void { - expandedRunIds.delete(runId); - workflowActions.collapseRun(`actions-page:jobs:${runId}`); - } - - function releaseAllRunOwners(): void { - for (const runId of expandedRunIds) { - workflowActions.collapseRun(`actions-page:jobs:${runId}`); - } - expandedRunIds.clear(); + workflowActions.loadJobs(selectedRef, runId); } onMount(() => { @@ -170,14 +151,10 @@ }; }); - function claimSelectedRepository(ref: ProviderRouteRef | null): Attachment { + function loadSelectedCatalog(ref: ProviderRouteRef | null): Attachment { return () => { if (!ref) return; - untrack(() => workflowActions.claimRepository(repositoryOwner, ref)); - return () => untrack(() => { - releaseAllRunOwners(); - workflowActions.releaseRepository(repositoryOwner); - }); + untrack(() => workflowActions.loadCatalog(ref)); }; } @@ -186,7 +163,7 @@
@@ -331,7 +308,6 @@ jobs={snapshot.jobs} loadingJobs={snapshot.loading.jobs} onexpand={expandRun} - oncollapse={collapseRun} /> {#if snapshot.runsPage.nextCursor && !snapshot.runsPage.exhausted}
diff --git a/frontend/src/lib/components/actions/ActionsPage.test.ts b/frontend/src/lib/components/actions/ActionsPage.test.ts index 9699e075de..d2211126df 100644 --- a/frontend/src/lib/components/actions/ActionsPage.test.ts +++ b/frontend/src/lib/components/actions/ActionsPage.test.ts @@ -261,10 +261,9 @@ describe("ActionsPage", () => { ); }); - it("uses distinct job owners for two expanded runs and collapsing one does not release the other", async () => { + it("reads jobs once per expanded run and keeps other expanded runs open on collapse", async () => { const workflowActions = createWorkflowActionsStore({ runtime }); - const expandRun = vi.spyOn(workflowActions, "expandRun"); - const collapseRun = vi.spyOn(workflowActions, "collapseRun"); + const loadJobs = vi.spyOn(workflowActions, "loadJobs"); render(ActionsPage, { context: new Map([[STORES_KEY, { workflowActions }]]), }); @@ -275,19 +274,15 @@ describe("ActionsPage", () => { await fireEvent.click(newest); await fireEvent.click(older); await screen.findByRole("button", { name: /Verify/ }); - expect(expandRun.mock.calls.map(([owner, , runId]) => [owner, runId])).toEqual([ - ["actions-page:jobs:alpha-run-1", "alpha-run-1"], - ["actions-page:jobs:alpha-run-2", "alpha-run-2"], - ]); + expect(loadJobs.mock.calls.map(([, runId]) => runId)).toEqual(["alpha-run-1", "alpha-run-2"]); await fireEvent.click(newest); - expect(collapseRun).toHaveBeenCalledWith("actions-page:jobs:alpha-run-1"); - expect(collapseRun).not.toHaveBeenCalledWith("actions-page:jobs:alpha-run-2"); + expect(loadJobs).toHaveBeenCalledTimes(2); expect(older.getAttribute("aria-expanded")).toBe("true"); expect(screen.getByRole("button", { name: /Verify/ })).toBeTruthy(); }); - it("releases every expanded run before switching workflows", async () => { + it("resets runs and jobs when switching workflows without refetching old jobs", async () => { const twoWorkflows: MockRouteOverride = (request) => { if (request.method !== "GET" || request.url.pathname !== "/api/v1/actions/github/acme/alpha/workflows") return null; @@ -321,23 +316,21 @@ describe("ActionsPage", () => { api = createMockApiFetch([twoWorkflows, workflowFixtures()]); globalThis.fetch = api.fetch; const workflowActions = createWorkflowActionsStore({ runtime }); - const collapseRun = vi.spyOn(workflowActions, "collapseRun"); - const selectWorkflow = vi.spyOn(workflowActions, "selectWorkflow"); render(ActionsPage, { context: new Map([[STORES_KEY, { workflowActions }]]), }); await fireEvent.click(await screen.findByRole("button", { name: /alpha deploy/ })); await fireEvent.click(await screen.findByRole("button", { name: /Run 7 alpha deploy/ })); - await fireEvent.click(await screen.findByRole("button", { name: /Run 6 alpha deploy/ })); - await screen.findByRole("button", { name: /Verify/ }); + await screen.findByRole("button", { name: /Publish/ }); const jobReads = api.requests.filter((request) => request.url.pathname.endsWith("/jobs")).length; await fireEvent.click(screen.getByRole("button", { name: /alpha verify/ })); - expect(collapseRun).toHaveBeenCalledWith("actions-page:jobs:alpha-run-1"); - expect(collapseRun).toHaveBeenCalledWith("actions-page:jobs:alpha-run-2"); - const switchOrder = selectWorkflow.mock.invocationCallOrder.at(-1)!; - expect(collapseRun.mock.invocationCallOrder.every((order) => order < switchOrder)).toBe(true); + await waitFor(() => { + const runReads = api.requests.filter((request) => request.url.pathname.endsWith("/runs")); + expect(runReads.at(-1)?.url.searchParams.get("workflow_id")).toBe("alpha-verify.yml"); + }); + expect(screen.queryByRole("button", { name: /Publish/ })).toBeNull(); expect(api.requests.filter((request) => request.url.pathname.endsWith("/jobs"))).toHaveLength(jobReads); }); @@ -535,7 +528,7 @@ describe("ActionsPage", () => { return jsonResponse( { accepted: true, - locating_run: false, + dispatch_id: `dispatch-${dispatches}`, actor: "maintainer", run: { actor: "maintainer", diff --git a/frontend/src/lib/components/actions/WorkflowDispatchDialog.test.ts b/frontend/src/lib/components/actions/WorkflowDispatchDialog.test.ts index 06d5d98730..27f0c75abb 100644 --- a/frontend/src/lib/components/actions/WorkflowDispatchDialog.test.ts +++ b/frontend/src/lib/components/actions/WorkflowDispatchDialog.test.ts @@ -47,7 +47,7 @@ it("restores trigger focus when canceled before admission", async () => { it.each([ [{ kind: "pending" } as const], - [{ kind: "uncertain", message: "Outcome unknown", candidates: [] } as const], + [{ kind: "uncertain", message: "Outcome unknown" } as const], [{ kind: "conflict" } as const], ])("refuses Escape and overlay dismissal while state is %s", async (state) => { const onclose = vi.fn(); @@ -196,7 +196,7 @@ it("presents definite rejection as dismissible and requires a fresh explicit cyc expect(onclose).toHaveBeenCalledOnce(); }); -it("renders ambiguous candidates and makes Dispatch again only begin a fresh confirmation cycle", async () => { +it("keeps an uncertain outcome open and makes Dispatch again only begin a fresh confirmation cycle", async () => { const onclose = vi.fn(); const onsubmit = vi.fn(); const onnewcycle = vi.fn(); @@ -209,21 +209,6 @@ it("renders ambiguous candidates and makes Dispatch again only begin a fresh con state: { kind: "uncertain", message: "The provider could not confirm the dispatch.", - candidates: [ - { - actor: "maintainer", - conclusion: "", - event: "workflow_dispatch", - head_sha: "candidate-head", - id: "candidate-9", - name: "Deploy", - ref: "main", - run_number: 9, - status: "queued", - web_url: "https://github.com/acme/app/actions/runs/9", - workflow_id: "deploy", - }, - ], }, trigger: null, onsubmit, @@ -232,10 +217,6 @@ it("renders ambiguous candidates and makes Dispatch again only begin a fresh con onnewcycle, }); - expect(screen.getByText("candidate-9")).toBeTruthy(); - expect(screen.getByRole("link", { name: "Open candidate run on provider" }).getAttribute("href")).toContain( - "/actions/runs/9", - ); await fireEvent.keyDown(window, { key: "Escape" }); expect(onclose).not.toHaveBeenCalled(); diff --git a/frontend/src/lib/components/actions/WorkflowDispatchForm.svelte b/frontend/src/lib/components/actions/WorkflowDispatchForm.svelte index 86f8c7d499..567b7d04af 100644 --- a/frontend/src/lib/components/actions/WorkflowDispatchForm.svelte +++ b/frontend/src/lib/components/actions/WorkflowDispatchForm.svelte @@ -219,21 +219,6 @@

{workflow.name}

- {#if presentation.candidates.length > 0} -
    - {#each presentation.candidates as candidate (candidate.id)} -
  • - {candidate.id} - {#if candidate.head_sha}{candidate.head_sha}{/if} - {#if candidate.web_url && isSafeExternalHTTPURL(candidate.web_url)} - - Open on provider - - {/if} -
  • - {/each} -
- {/if} {#if onnewcycle}{/if}
{:else} @@ -333,13 +318,11 @@ .workflow-input-dropdown { min-width: 0; } .workflow-input-dropdown :global(.kit-select-dropdown), .workflow-input-dropdown :global(.kit-select-dropdown__trigger) { width: 100%; min-width: 0; } - .field-error, .notice, .run-details, .candidate-runs { font-size: var(--font-size-sm); } + .field-error, .notice, .run-details { font-size: var(--font-size-sm); } .field-error, .notice--error { color: var(--status-danger-text, var(--text-danger)); } .notice--success { color: var(--status-success-text, var(--text-success)); } - .run-details, .candidate-runs { display: grid; gap: var(--space-2); } + .run-details { display: grid; gap: var(--space-2); } .run-details > div { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: var(--space-3); } .run-details dd { margin: 0; min-width: 0; overflow-wrap: anywhere; } - .candidate-runs { margin: 0; padding: 0; list-style: none; } - .candidate-runs li { display: flex; min-width: 0; flex-wrap: wrap; gap: var(--space-2) var(--space-3); } a { color: var(--accent-blue); } diff --git a/frontend/src/lib/components/actions/WorkflowDispatchForm.test.ts b/frontend/src/lib/components/actions/WorkflowDispatchForm.test.ts index 1f99c574e0..1564e559cb 100644 --- a/frontend/src/lib/components/actions/WorkflowDispatchForm.test.ts +++ b/frontend/src/lib/components/actions/WorkflowDispatchForm.test.ts @@ -288,7 +288,6 @@ describe("WorkflowDispatchForm", () => { state: { kind: "uncertain", message: "The provider may have accepted this run. Verify on the provider before trying again.", - candidates: [], }, }); expect(screen.getByRole("alert").textContent).toContain("may have accepted"); diff --git a/frontend/src/lib/components/actions/WorkflowRunList.svelte b/frontend/src/lib/components/actions/WorkflowRunList.svelte index 4bdb603779..067eb4ff59 100644 --- a/frontend/src/lib/components/actions/WorkflowRunList.svelte +++ b/frontend/src/lib/components/actions/WorkflowRunList.svelte @@ -13,10 +13,9 @@ jobs: Readonly>; loadingJobs: readonly string[]; onexpand: (runId: string) => void; - oncollapse: (runId: string) => void; } - let { runs, jobs, loadingJobs, onexpand, oncollapse }: Props = $props(); + let { runs, jobs, loadingJobs, onexpand }: Props = $props(); let expandedRuns = $state>({}); let expandedJobs = $state>({}); @@ -26,7 +25,7 @@ function toggleRun(id: string): void { const expanded = expandedRuns[id] === true; expandedRuns[id] = !expanded; - if (expanded) oncollapse(id); else onexpand(id); + if (!expanded) onexpand(id); } function providerName(url: string): string { try { diff --git a/frontend/src/lib/components/actions/WorkflowRunList.test.ts b/frontend/src/lib/components/actions/WorkflowRunList.test.ts index f8541d973c..e78c5903e6 100644 --- a/frontend/src/lib/components/actions/WorkflowRunList.test.ts +++ b/frontend/src/lib/components/actions/WorkflowRunList.test.ts @@ -40,7 +40,7 @@ const jobs: Record = { }; it("exposes compact textual run data, local time, and secure provider links", () => { - render(WorkflowRunList, { runs, jobs: {}, loadingJobs: [], onexpand: vi.fn(), oncollapse: vi.fn() }); + render(WorkflowRunList, { runs, jobs: {}, loadingJobs: [], onexpand: vi.fn() }); const row = screen.getByRole("button", { name: /Run 42 Deploy/ }); expect(row.textContent).toContain("#42"); expect(row.textContent).toContain("Deploy"); @@ -62,7 +62,6 @@ it.each([ jobs: {}, loadingJobs: [], onexpand: vi.fn(), - oncollapse: vi.fn(), }); expect(screen.getByRole("link", { name: `Open on ${providerLabel}` })).toBeTruthy(); }); @@ -73,15 +72,13 @@ it("omits unsafe provider links", () => { jobs: {}, loadingJobs: [], onexpand: vi.fn(), - oncollapse: vi.fn(), }); expect(screen.queryByRole("link")).toBeNull(); }); -it("owns one lazy job request per exact expand and collapse transition and preserves provider order", async () => { +it("requests jobs only when a run expands and preserves provider order", async () => { const onexpand = vi.fn(); - const oncollapse = vi.fn(); - const view = render(WorkflowRunList, { runs, jobs, loadingJobs: [], onexpand, oncollapse }); + const view = render(WorkflowRunList, { runs, jobs, loadingJobs: [], onexpand }); const disclosure = screen.getByRole("button", { name: /Run 42 Deploy/ }); expect(disclosure.getAttribute("aria-expanded")).toBe("false"); @@ -104,11 +101,11 @@ it("owns one lazy job request per exact expand and collapse transition and prese ).toEqual([expect.stringContaining("Upload"), expect.stringContaining("Build")]); await fireEvent.click(disclosure); - expect(oncollapse).toHaveBeenCalledTimes(1); - expect(oncollapse).toHaveBeenCalledWith("run-2"); + expect(disclosure.getAttribute("aria-expanded")).toBe("false"); + expect(onexpand).toHaveBeenCalledTimes(1); await fireEvent.click(disclosure); expect(onexpand).toHaveBeenCalledTimes(2); - await view.rerender({ runs, jobs, loadingJobs: ["run-2"], onexpand, oncollapse }); + await view.rerender({ runs, jobs, loadingJobs: ["run-2"], onexpand }); expect(screen.getByText("Loading jobs…").getAttribute("role")).toBe("status"); }); diff --git a/frontend/src/lib/components/actions/workflow-dispatch-presentation.test.ts b/frontend/src/lib/components/actions/workflow-dispatch-presentation.test.ts index f9f7ab6a2f..1133c7290d 100644 --- a/frontend/src/lib/components/actions/workflow-dispatch-presentation.test.ts +++ b/frontend/src/lib/components/actions/workflow-dispatch-presentation.test.ts @@ -4,7 +4,7 @@ import type { WorkflowActionsError, WorkflowActionsSnapshot, WorkflowDispatchState, -} from "../../stores/workflow-actions-workflow.js"; +} from "../../stores/workflow-actions.svelte.js"; import { workflowActionsErrorMessage, workflowDispatchPresentation } from "./workflow-dispatch-presentation.js"; const ref = { @@ -15,16 +15,6 @@ const ref = { repoPath: "acme/app", } as const; -const request = { - id: "dispatch-1", - ref, - workflowId: "deploy.yml", - expectedDefinitionSha: "definition-a", - dispatchRef: "main", - inputs: {}, - startedAt: 1, -} as const; - const run = { actor: "maintainer", conclusion: "success", @@ -78,7 +68,7 @@ function snapshot(dispatch?: WorkflowDispatchState): WorkflowActionsSnapshot { runsPage: { nextCursor: null, exhausted: true, loadingMore: false }, jobs: {}, loading: { catalog: false, runs: false, jobs: [] }, - dispatches: dispatch ? [dispatch] : [], + dispatches: dispatch ? { "deploy.yml": dispatch } : {}, catalogRefreshErrors: {}, error: null, }; @@ -87,45 +77,41 @@ function snapshot(dispatch?: WorkflowDispatchState): WorkflowActionsSnapshot { describe("workflow dispatch presentation", () => { it("projects idle, pending, locating, and succeeded states", () => { expect(workflowDispatchPresentation(null, "deploy.yml")).toEqual({ kind: "idle" }); - expect(workflowDispatchPresentation(snapshot({ kind: "pending", request }), "deploy.yml")).toEqual({ - kind: "pending", - }); - expect(workflowDispatchPresentation(snapshot({ kind: "locating", request }), "deploy.yml")).toEqual({ + expect(workflowDispatchPresentation(snapshot({ kind: "pending" }), "other.yml")).toEqual({ kind: "idle" }); + expect(workflowDispatchPresentation(snapshot({ kind: "pending" }), "deploy.yml")).toEqual({ kind: "pending" }); + expect(workflowDispatchPresentation(snapshot({ kind: "locating", dispatchId: "d1" }), "deploy.yml")).toEqual({ kind: "locating", }); - expect(workflowDispatchPresentation(snapshot({ kind: "succeeded", request }), "deploy.yml")).toEqual({ + expect(workflowDispatchPresentation(snapshot({ kind: "succeeded", dispatchId: "d1" }), "deploy.yml")).toEqual({ kind: "succeeded", }); - expect(workflowDispatchPresentation(snapshot({ kind: "succeeded", request, run }), "deploy.yml")).toEqual({ + expect(workflowDispatchPresentation(snapshot({ kind: "succeeded", dispatchId: "d1", run }), "deploy.yml")).toEqual({ kind: "succeeded", run, }); }); - it("projects failed, timeout, and uncertain recovery branches", () => { - expect(workflowDispatchPresentation(snapshot({ kind: "failed", request, error: rejected }), "deploy.yml")).toEqual({ + it("projects failed, unresolved, and uncertain recovery branches", () => { + expect(workflowDispatchPresentation(snapshot({ kind: "failed", error: rejected }), "deploy.yml")).toEqual({ kind: "failed", message: "The ref is invalid.", }); - expect(workflowDispatchPresentation(snapshot({ kind: "locating_timed_out", request }), "deploy.yml")).toEqual({ + expect(workflowDispatchPresentation(snapshot({ kind: "unresolved", dispatchId: "d1" }), "deploy.yml")).toEqual({ kind: "succeeded", message: "The provider accepted the workflow, but its run was not observed.", }); - expect( - workflowDispatchPresentation( - snapshot({ kind: "uncertain", request, error: rejected, candidates: [run] }), - "deploy.yml", - ), - ).toEqual({ + expect(workflowDispatchPresentation(snapshot({ kind: "uncertain", error: rejected }), "deploy.yml")).toEqual({ kind: "uncertain", message: "The ref is invalid.", - candidates: [run], }); }); it("uses the shared reload fallback and cycle-specific conflict error", () => { + expect(workflowDispatchPresentation(snapshot({ kind: "failed", error: conflict }), "deploy.yml")).toEqual({ + kind: "conflict", + }); const current: WorkflowActionsSnapshot = { - ...snapshot({ kind: "failed", request, error: conflict }), + ...snapshot({ kind: "failed", error: conflict }), catalogRefreshErrors: { "deploy.yml": reloadFailure }, }; expect(workflowDispatchPresentation(current, "deploy.yml")).toEqual({ diff --git a/frontend/src/lib/components/actions/workflow-dispatch-presentation.ts b/frontend/src/lib/components/actions/workflow-dispatch-presentation.ts index f13e99f98b..b144596c7c 100644 --- a/frontend/src/lib/components/actions/workflow-dispatch-presentation.ts +++ b/frontend/src/lib/components/actions/workflow-dispatch-presentation.ts @@ -1,9 +1,10 @@ import { ProblemCodes } from "../../api/problems.js"; import { apiErrorMessage } from "../../api/runtime.js"; -import type { components } from "../../api/generated/schema.js"; -import type { WorkflowActionsError, WorkflowActionsSnapshot } from "../../stores/workflow-actions-workflow.js"; - -type WorkflowRun = components["schemas"]["WorkflowRunResponse"]; +import type { + WorkflowActionsError, + WorkflowActionsSnapshot, + WorkflowRun, +} from "../../stores/workflow-actions.svelte.js"; export type WorkflowDispatchPresentationState = | { readonly kind: "idle" } @@ -11,11 +12,7 @@ export type WorkflowDispatchPresentationState = | { readonly kind: "locating" } | { readonly kind: "succeeded"; readonly run?: WorkflowRun; readonly message?: string } | { readonly kind: "failed"; readonly message: string } - | { - readonly kind: "uncertain"; - readonly message: string; - readonly candidates: readonly WorkflowRun[]; - } + | { readonly kind: "uncertain"; readonly message: string } | { readonly kind: "conflict"; readonly reloadError?: string }; const outcomeFallback = "The workflow outcome could not be confirmed."; @@ -25,7 +22,7 @@ export function workflowActionsErrorMessage(error: WorkflowActionsError, fallbac if (error._tag === "ApiProblemError") { return apiErrorMessage(error.problem, fallback); } - if ("cause" in error && error.cause instanceof Error) return error.cause.message; + if (error.cause instanceof Error) return error.cause.message; return fallback; } @@ -34,38 +31,32 @@ export function workflowDispatchPresentation( workflowId: string | null, ): WorkflowDispatchPresentationState { if (!workflowId) return { kind: "idle" }; - const dispatch = [...(snapshot?.dispatches ?? [])] - .reverse() - .find((candidate) => candidate.request.workflowId === workflowId); + const dispatch = snapshot?.dispatches[workflowId]; if (!dispatch) return { kind: "idle" }; - if (dispatch.kind === "pending") return { kind: "pending" }; - if (dispatch.kind === "locating") return { kind: "locating" }; - if (dispatch.kind === "succeeded") { - return dispatch.run === undefined ? { kind: "succeeded" } : { kind: "succeeded", run: dispatch.run }; - } - if ( - dispatch.kind === "failed" && - dispatch.error._tag === "ApiProblemError" && - dispatch.error.problem.code === ProblemCodes.conflict && - dispatch.error.problem.details?.["reason"] === "workflow_definition_changed" - ) { - const reloadError = snapshot?.catalogRefreshErrors[workflowId]; - return reloadError - ? { kind: "conflict", reloadError: workflowActionsErrorMessage(reloadError, reloadFallback) } - : { kind: "conflict" }; - } - if (dispatch.kind === "failed") { - return { kind: "failed", message: workflowActionsErrorMessage(dispatch.error, outcomeFallback) }; - } - if (dispatch.kind === "locating_timed_out") { - return { - kind: "succeeded", - message: "The provider accepted the workflow, but its run was not observed.", - }; + switch (dispatch.kind) { + case "pending": + return { kind: "pending" }; + case "locating": + return { kind: "locating" }; + case "succeeded": + return dispatch.run === undefined ? { kind: "succeeded" } : { kind: "succeeded", run: dispatch.run }; + case "unresolved": + return { kind: "succeeded", message: "The provider accepted the workflow, but its run was not observed." }; + case "uncertain": + return { kind: "uncertain", message: workflowActionsErrorMessage(dispatch.error, outcomeFallback) }; + case "failed": { + const { error } = dispatch; + if ( + error._tag === "ApiProblemError" && + error.problem.code === ProblemCodes.conflict && + error.problem.details?.["reason"] === "workflow_definition_changed" + ) { + const reloadError = snapshot?.catalogRefreshErrors[workflowId]; + return reloadError + ? { kind: "conflict", reloadError: workflowActionsErrorMessage(reloadError, reloadFallback) } + : { kind: "conflict" }; + } + return { kind: "failed", message: workflowActionsErrorMessage(error, outcomeFallback) }; + } } - return { - kind: "uncertain", - message: workflowActionsErrorMessage(dispatch.error, outcomeFallback), - candidates: dispatch.candidates, - }; } diff --git a/frontend/src/lib/components/detail/PullDetail.svelte b/frontend/src/lib/components/detail/PullDetail.svelte index ddd0c918ac..8b851f2503 100644 --- a/frontend/src/lib/components/detail/PullDetail.svelte +++ b/frontend/src/lib/components/detail/PullDetail.svelte @@ -1,6 +1,5 @@